Skip to content

Commit b95a856

Browse files
fix(metadata-protocol,rest,runtime,mcp): the remaining write doors answer a write that landed under a failing roll-up with their own success (#28843)
domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X8dNy8r76WjFsVdvaSJbXXR)_ Fixes #28782 Clause-②: no Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches what a write door answers when a parent roll-up cannot be recomputed after the write. ## 维护者速读 - 问题:子记录写入已经落库、只是父记录汇总重算失败(`ERR_SUMMARY_RECOMPUTE`)时,#28781 只修了 REST 和分发器的单条 POST/PATCH/DELETE;表单提交、createMany、clone 仍答 500,batch/updateMany/deleteMany 把已存的行报成失败并停下,客户端重试会产生重复记录。 - 现在:这些入口都答各自已有的成功响应(不加错误码、字段、状态、header),复用 #28781 的载体;汇总列过期按字段每进程在日志里以 error 说一次。 - 原子批量(`options.atomic` 和跨对象 `POST /batch`)不变:拒绝发生在事务里,整批回滚,什么都没落库,回滚应答本来就是事实。 - 写入前的拒绝(校验、权限、约束)完全照旧;导入和种子加载未改;未改 `packages/spec/**`。 - 需要你看:MCP 的 `update_record` / `delete_record` 也走同一路径,本卡只点名了 `create_record`,未处理(见 Acceptance notes)。 ## What changes One reading for every remaining door, through #28781's carrier in `@objectstack/types` (`withEarnedAnswer` / `earnedAnswerOf` / `staleSummaryAnswerLines`); no new carrier, code, member, status or header. - **Producers attach the answer the write earned to the refusal and rethrow**, as `createData` does since #28781, so a caller that does not read it still gets the refusal: - `packages/metadata-protocol`: `createManyData` (`protocol-batch-data.ts`; one multi-row `engine.insert`, so `written` is every row) and `cloneData` (`protocol.ts`). - `packages/runtime`: `callData`'s ObjectQL fallback (`action-execution.ts`), its create, update and delete arms — the dispatcher's `/data` door (`callDataWrite`, #28781) already reads the answer. - `packages/mcp`: the stdio bridge's `create` (`stdio-data-bridge.ts`, `engine.insert` direct). - **Doors read it**: - `packages/rest`: public form submit (`form-routes.ts`, at the `createData` call, then the door's own `201 { id, fields? }` projection), `createMany` (`batch-routes.ts`) and clone (`data-action-routes.ts`) answer `201` with the earned body. `stale-summary-answer.ts` gains `earnedAnswerLogged` (log + answer, no dropped-fields header — these doors' success paths set none); `earnedWriteDespiteStaleSummary` is now that plus the header. - `packages/mcp`: the `create_record` tool (`mcp-http-tools.ts`), for both bridges — the runtime bridge reaches the protocol's `createData` through `callData('create')`. - **Per-row bulk loops** (`runBatchDataLoop`, `runUpdateManyLoop`, `runDeleteManyLoop` in `protocol-batch-data.ts`): each `engine.insert` / `update` / `delete` gets `.catch(landedDespiteStaleSummary)`, which on the non-atomic arm hands back the refusal's `written` so the row is built by the arm's own success code (`{ id, success: true, data, index }`, the delete arm's `success`), and the loop goes on. On the atomic arm it rethrows: the refusal is raised inside the batch transaction and the rollback answer is the truth. These loops answer a normal success, so they log the stale column themselves (`[Protocol]`, same line). **What `written` carries.** Measured in objectql: a multi-row insert throws once, after every row is stored, with `written` = all rows (`engine.ts` insert path); a per-row loop's refusal belongs to that row only. Nothing throws after a partial set. ## Tests Each new file red on `origin/main` `c9a9981da` (implementation reverted on disk, `metadata-protocol` rebuilt) and green on the head. - `packages/rest` — `data-write-summary-recompute-other-doors.test.ts` (9, `RestServer` → protocol → `ObjectQL` → real `SqlDriver` on better-sqlite3): form submit `201 { id }`, row stored once; `createMany` `201 { object, records, count }`; per-object `batch` create, `updateMany`, `deleteMany` (non-atomic) all rows `success`, `succeeded: 2`, values stored once and `child_sum` recomputed; clone `201`; an atomic batch still rolls back with nothing stored; a pre-write refusal (missing required parent) answers as before; one `error` line. Base: 7 red, 2 green (the atomic and pre-write guards). - `packages/runtime` — `domains/data-write-summary-recompute-fallback.test.ts` (5): the dispatcher with no `protocol` service, POST 201 / PATCH 200 / DELETE 200, stored once, roll-ups recomputed, pre-write refusal unchanged, one host-logger line. Base: 4 red. - `packages/mcp` — `mcp-create-record-stale-summary.test.ts` (4, over the HTTP `/mcp` transport): stdio bridge success body with the row stored once; runtime-bridge shape relays the attached answer; a pre-write refusal is still `isError`; one log line. Base: 3 red. - Ablations, each landed on disk then restored (`git diff HEAD` empty after each): form reading off → 1 red; `createMany` reading off → 1 red; clone reading off → 1 red; bulk-loop helper rethrowing always (mp rebuilt) → 3 red (batch, updateMany, deleteMany); `createManyData` throwing the bare refusal (rebuilt) → 1 red; `cloneData` the same (rebuilt) → 1 red; fallback throwing the bare refusal → runtime 4 red; MCP tool reading off → 3 red; stdio bridge throwing the bare refusal → 1 red. - Suites on `6e3035f3`: `@objectstack/metadata-protocol` 22074 passed (26 skipped), `@objectstack/rest` 7217 passed (352 skipped), `@objectstack/runtime` 9242 passed (19 skipped), `@objectstack/mcp` 635/635. - Full dogfood on `6e3035f3` (after merging `main`, closure rebuilt): Test Files 380 passed, 3 skipped (383); Tests 3114 passed, 28 skipped (3142). ## Acceptance notes - **No wire shape change.** Every door answers a body and status it already publishes for the same write when every roll-up recomputes; `@objectstack/types` is untouched. - **Atomic arms unchanged, by measurement.** `options.atomic` batches and the cross-object `POST /batch` raise the refusal inside `engine.transaction`; the loop's failure rolls the batch back, so no row landed and a retry duplicates nothing. Committing them under a stale roll-up would be a new behaviour, not the reading this card asks for. - **Not changed.** MCP `update_record` / `delete_record` (same `callData` path, not named by the card), `insertManyData` (the import runner's partial create, ruling 3), `import-runner.ts`, `seed-loader.ts`. - No `packages/spec/**` edit; the code is read inline (`check:error-code-provenance` exit 0). ## Verification Live probe, base vs head, each on a fresh showcase boot. To store the stale roll-up the head's save gate refuses, both boots ran with `@objectstack/lint`'s roll-up entry off at the runtime door (`runtimeTypes: []`, restored after); base = `metadata-protocol`/`rest`/`runtime`/`mcp` at `origin/main`, head = this branch. ``` pnpm dev -- --fresh -p <port> curl -X POST /api/v1/auth/sign-in/email -d '{"email":"admin@objectos.ai","password":"admin123"}' curl -X POST /api/v1/packages -d '{"manifest":{"id":"app.qsf","name":"QSF","version":"0.1.0","type":"app","namespace":"qsf"},"enableOnInstall":true}' curl -X PUT '/api/v1/meta/object/qsf_parent?mode=draft&package=app.qsf' -d '<child_sum = sum(amount), child_count = count, probe_sf = sum(qsf_child.memo)>' curl -X PUT '/api/v1/meta/object/qsf_child?mode=draft&package=app.qsf' -d '<memo text, amount number, parent master_detail>' curl -X POST /api/v1/packages/app.qsf/publish-drafts -d '{}' curl -X POST /api/v1/data/qsf_child/batch -d '{"operation":"create","records":[{"data":{"name":"b1","amount":3,"parent":"qsfp1"}},{"data":{"name":"b2","amount":4,"parent":"qsfp1"}}]}' curl -X POST /api/v1/data/qsf_child/createMany -d '[{"name":"m1","amount":1,"parent":"qsfp1"}]' curl -X POST /api/v1/data/qsf_child/qsfc2/clone -d '{"name":"k1"}' curl -X POST /api/v1/forms/qsf/submit -d '{"name":"form1","amount":7,"parent":"qsfp1"}' ``` | door | base | head | |---|---|---| | `batch` (create ×2, non-atomic) | `200`, row 0 `success:false` `ERR_SUMMARY_RECOMPUTE`, row 1 `NOT_ATTEMPTED`; **b1 stored**, b2 not | `200`, `succeeded: 2`, both rows `success:true`, b1 and b2 stored once | | `createMany` | `500 INTERNAL_ERROR`, m1 stored | `201 { object, records, count }`, m1 stored once | | clone | `500 INTERNAL_ERROR`, k1 stored | `201 { object, id, sourceId, record }`, k1 stored once | | form submit | `400 reference_not_found` before the write | same | The form door does not reach the post-write path live: it runs as the anonymous caller even with a session cookie, and the reference check refuses a master-detail parent that caller cannot see, before the write, on base and head alike. Its post-write answer is pinned on a real store by the REST test above. The head boot logged one `error` line for `qsf_parent.probe_sf`. Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 71 commands on the diff (63 before the engine-double ledger change widened it); all 71 exit 0 (`check:engine-double-contract` first read exit 1 on the new MCP double, fixed by routing it through the engine dispatch asserts and `--write`), and `--ran` reconciles 71 derived, 71 run, 0 NOT-MEASURED. `pnpm --filter @objectstack/spec check:error-code-provenance`: exit 0. Scoped eslint over the 12 changed `.ts` files: exit 0. `pnpm check:nul-bytes`: exit 0. `tsc --noEmit` of `metadata-protocol`, `rest`, `runtime`, `mcp`: exit 0 each. After the final merge of `main` (`a4d7f538`): `check:engine-double-contract` and `check:error-code-provenance` exit 0, closures rebuilt, the new and #28781 door files green (rest 14, runtime 9, mcp 36 with the stdio exposure suite), `metadata-protocol` typecheck exit 0; the full dogfood above ran on `6e3035f3`, before that merge. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kV6ptpTnU6Hjb2mE4)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f4cbc9 commit b95a856

14 files changed

Lines changed: 759 additions & 27 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/rest': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/mcp': patch
6+
---
7+
8+
fix(metadata-protocol,rest,runtime,mcp): the remaining write doors answer a write that landed under a failing roll-up with their own success (#28782)
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
Skills-docs: none — no file under `skills/**` teaches what a write door answers when a parent roll-up cannot be recomputed after the write.
13+
14+
When a child write is stored but a parent's roll-up summary cannot be recomputed after it (`ERR_SUMMARY_RECOMPUTE`, "the triggering records WERE written"), #28781 made the REST and dispatcher single-record doors answer the write's own success. The other write doors now do the same, each with the success answer it already publishes — no new code, member, status or header:
15+
16+
- **Public form submit** (`POST /forms/:slug/submit`): `201 { id, fields? }` instead of `500`.
17+
- **`createMany`** and **clone**: `201` with their usual body instead of `500`. `createManyData` and `cloneData` attach the answer to the refusal the way `createData` does, so a caller that does not read it still gets the refusal.
18+
- **Per-object `batch`, `updateMany`, `deleteMany`** (non-atomic): a stored row is a `success` row built from the refusal's `written`, and the loop goes on to the next row. Before, the row came back `success: false` with the refusal and every later row was not attempted, so a client retrying the failed rows duplicated the stored one. An **atomic** batch is unchanged: the refusal is raised inside its transaction, the batch rolls back, and nothing landed.
19+
- **`callData`'s ObjectQL fallback** (a host with no `protocol` service): its create, update and delete arms attach their answer to the refusal, so the dispatcher's `/data` door answers it.
20+
- **MCP `create_record`**: answers the create on both bridges. The runtime bridge relays the protocol's attached answer, and the stdio bridge builds its own.
21+
22+
The stale summary column is logged at `error` once per field per process, with the fix, as on the single-record doors. A refusal before the write (validation, permission, constraint) answers exactly as before. The import runner and seed loader are untouched.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28782] The MCP `create_record` tool answers a record the engine STORED but
5+
* whose parent roll-up it could not recompute with the create it is — never
6+
* the refusal, which told an agent its create failed and invited it to create
7+
* the record again.
8+
*
9+
* ## The defect, measured on `origin/main` (`c9a9981da`) before this file
10+
*
11+
* The engine reports that case as `ERR_SUMMARY_RECOMPUTE` ("the triggering
12+
* records WERE written", carrying `written`). #28781 taught the REST and
13+
* dispatcher single-record doors to answer the write's success; both MCP
14+
* bridges still let the refusal reach the tool, which rendered it as an
15+
* `isError` result:
16+
*
17+
* - the runtime bridge (`callData('create')` → the protocol's `createData`,
18+
* which attaches the earned answer since #28781) — the tool never read it;
19+
* - the stdio bridge (`createStdioDataBridge`, `engine.insert` direct) built
20+
* no answer at all.
21+
*
22+
* ## What is pinned
23+
*
24+
* - over the HTTP `/mcp` transport, `create_record` answers the bridge's
25+
* success body for both bridges, and the stdio bridge stores the row once;
26+
* - a refusal BEFORE the write is still an `isError` result (ruling 3);
27+
* - the stale column is logged once, at `error`.
28+
*
29+
* The engine is a double whose `insert` stores the row and then throws the
30+
* refusal as objectql's `SummaryRecomputeError` does (this package has no SQL
31+
* driver); the real engine's throw is pinned by #28781's REST suite.
32+
*/
33+
34+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
35+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
36+
import type { ExecutionContext } from '@objectstack/spec/kernel';
37+
import { withEarnedAnswer } from '@objectstack/types';
38+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
39+
40+
import { MCPServerRuntime } from './mcp-server-runtime.js';
41+
import { createStdioDataBridge } from './stdio-data-bridge.js';
42+
import type { McpDataBridge } from './mcp-http-tools.js';
43+
44+
const CHILD = {
45+
name: 'qsf_child',
46+
label: 'Child',
47+
fields: {
48+
id: { name: 'id', type: 'text' },
49+
name: { name: 'name', type: 'text' },
50+
amount: { name: 'amount', type: 'number' },
51+
},
52+
enable: {},
53+
};
54+
55+
/** objectql's `SummaryRecomputeError`, as a write's caller reads it: by `code`, carrying `written`. */
56+
function staleRefusal(written: unknown): Error {
57+
return Object.assign(new Error('Failed to recompute 1 roll-up summary value(s) after write; the triggering records WERE written.'), {
58+
code: 'ERR_SUMMARY_RECOMPUTE',
59+
written,
60+
failures: [{ childObject: 'qsf_child', parentObject: 'qsf_parent', parentId: 'p1', field: 'probe_sf', error: new Error('sum over text') }],
61+
});
62+
}
63+
64+
/** An engine whose `insert` stores the row, then reports the stale roll-up — or refuses before storing anything. */
65+
function engineDouble(stored: Record<string, unknown>[]) {
66+
return {
67+
find: vi.fn(async () => []),
68+
findOne: vi.fn(async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }),
69+
insert: vi.fn(async (_o: string, data: Record<string, unknown>) => {
70+
if (data.name === 'refused') {
71+
throw Object.assign(new Error('name is not allowed'), { code: 'VALIDATION_FAILED', status: 400 });
72+
}
73+
const row = { id: `n${stored.length + 1}`, ...data };
74+
stored.push(row);
75+
throw staleRefusal(row);
76+
}),
77+
update: vi.fn(async (_o: string, data: Record<string, unknown>, options?: any) => { assertEngineUpdateDispatch(data, options); return null; }),
78+
delete: vi.fn(async (_o: string, options?: any) => { assertEngineDeleteDispatch(options); return true; }),
79+
count: vi.fn(async () => 0),
80+
} as unknown as IDataEngine;
81+
}
82+
83+
function metadataDouble(): IMetadataService {
84+
return {
85+
listObjects: vi.fn(async () => [CHILD]),
86+
getObject: vi.fn(async () => CHILD),
87+
} as unknown as IMetadataService;
88+
}
89+
90+
let nextId = 1;
91+
async function createRecord(bridge: McpDataBridge, data: Record<string, unknown>) {
92+
const body = { jsonrpc: '2.0', id: nextId++, method: 'tools/call', params: { name: 'create_record', arguments: { objectName: 'qsf_child', data } } };
93+
const runtime = new MCPServerRuntime({ name: 'pin', version: '0.0.0' });
94+
const res = await runtime.handleHttpRequest(
95+
new Request('http://localhost/api/v1/mcp', {
96+
method: 'POST',
97+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
98+
body: JSON.stringify(body),
99+
}),
100+
{ bridge: bridge as any, parsedBody: body },
101+
);
102+
expect(res.status).toBe(200);
103+
const json = (await res.json()) as { result?: any; error?: unknown };
104+
expect(json.error, JSON.stringify(json.error)).toBeUndefined();
105+
return json.result as { content: Array<{ text: string }>; isError?: boolean };
106+
}
107+
108+
let errorLog: ReturnType<typeof vi.spyOn>;
109+
beforeAll(() => { errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); });
110+
afterAll(() => { errorLog.mockRestore(); });
111+
112+
describe('[#28782] MCP create_record answers a record stored under a failing roll-up as the create it is', () => {
113+
it('the stdio bridge: the success body, the row stored once', async () => {
114+
const stored: Record<string, unknown>[] = [];
115+
const engine = engineDouble(stored);
116+
const bridge = createStdioDataBridge({
117+
engine,
118+
metadataService: metadataDouble(),
119+
resolvePrincipal: async () => ({ userId: 'u1' }) as unknown as ExecutionContext,
120+
});
121+
const result = await createRecord(bridge, { name: 'mcp1', amount: 5 });
122+
expect(result.isError, result.content[0]?.text).toBeFalsy();
123+
expect(JSON.parse(result.content[0]!.text)).toEqual({ object: 'qsf_child', id: 'n1', record: { id: 'n1', name: 'mcp1', amount: 5 } });
124+
expect(engine.insert).toHaveBeenCalledTimes(1);
125+
expect(stored).toHaveLength(1);
126+
});
127+
128+
it('the runtime bridge: the answer the protocol attached to the refusal', async () => {
129+
const answer = { object: 'qsf_child', id: 'r1', record: { id: 'r1', name: 'mcp2', amount: 6 } };
130+
const bridge = {
131+
async listObjects() { return [{ name: 'qsf_child', label: 'Child', fieldCount: 3 }]; },
132+
async describeObject() { return { name: 'qsf_child', fields: [] }; },
133+
async query(object: string) { return { object, records: [], total: 0 }; },
134+
async get() { return null; },
135+
async create() { throw withEarnedAnswer(staleRefusal(answer.record) as any, answer); },
136+
async update() { return null; },
137+
async remove() { return null; },
138+
} as unknown as McpDataBridge;
139+
const result = await createRecord(bridge, { name: 'mcp2', amount: 6 });
140+
expect(result.isError, result.content[0]?.text).toBeFalsy();
141+
expect(JSON.parse(result.content[0]!.text)).toEqual(answer);
142+
});
143+
144+
it('a refusal before the write is still an error result, and nothing is stored', async () => {
145+
const stored: Record<string, unknown>[] = [];
146+
const bridge = createStdioDataBridge({
147+
engine: engineDouble(stored),
148+
metadataService: metadataDouble(),
149+
resolvePrincipal: async () => ({ userId: 'u1' }) as unknown as ExecutionContext,
150+
});
151+
const result = await createRecord(bridge, { name: 'refused' });
152+
expect(result.isError).toBe(true);
153+
expect(result.content[0]!.text).toContain('VALIDATION_FAILED');
154+
expect(stored).toEqual([]);
155+
});
156+
157+
it('the stale column is logged once, at error', () => {
158+
const lines = errorLog.mock.calls.map((c: unknown[]) => String(c[0])).filter((l: string) => l.includes('could not be recomputed'));
159+
expect(lines).toHaveLength(1);
160+
expect(lines[0]).toContain('"qsf_parent.probe_sf"');
161+
});
162+
});

‎packages/mcp/src/mcp-http-tools.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { StandardErrorCode } from '@objectstack/spec/api';
5050
// [#23701] The one key every door that translates the engine's unique-violation
5151
// envelope asks (the registered code AND the class name, never message text),
5252
// and the one classification whose arm states the wire code for it.
53-
import { isEngineDuplicateRecordEnvelope, structuredCodeAnswer } from '@objectstack/types';
53+
import { earnedAnswerOf, isEngineDuplicateRecordEnvelope, staleSummaryAnswerLines, structuredCodeAnswer, type SummaryRecomputeRefusal } from '@objectstack/types';
5454
import {
5555
MCP_OAUTH_SCOPE_DATA_READ,
5656
MCP_OAUTH_SCOPE_DATA_WRITE,
@@ -1332,6 +1332,14 @@ export function registerObjectTools(
13321332
try {
13331333
return textResult(await bridge.create(objectName, data));
13341334
} catch (err) {
1335+
// [#28782] A record the engine stored but whose parent roll-up it
1336+
// could not recompute answers the create it is (#28781's carrier),
1337+
// not the refusal: an agent that read an error would create it again.
1338+
const earned = earnedAnswerOf(err);
1339+
if (earned) {
1340+
for (const line of staleSummaryAnswerLines(err as SummaryRecomputeRefusal, objectName)) console.error(`[MCP] ${line}`);
1341+
return textResult(earned);
1342+
}
13351343
return errorResultWithCode(err);
13361344
}
13371345
},

‎packages/mcp/src/stdio-data-bridge.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ import {
146146
diagnoseObjectListRead,
147147
type DiagnosedObjectListing,
148148
} from './metadata-completeness.js';
149+
import { staleSummaryRefusalOrThrow, withEarnedAnswer, type SummaryRecomputeRefusal } from '@objectstack/types';
149150

150151
/**
151152
* [#25138] The object reads this transport asks of the `protocol` service,
@@ -1058,9 +1059,18 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge
10581059
const context = await resolvePrincipal();
10591060
await enforceApiExposure(reads, object, GATED_ACTIONS.create, context);
10601061
enforceApiKeyManagementCredential(object, GATED_ACTIONS.create);
1061-
const written = (await engine.insert(object, data, { context })) as
1062-
| Record<string, unknown>
1063-
| undefined;
1062+
// [#28782] A record stored under a parent roll-up the engine could not
1063+
// recompute (`ERR_SUMMARY_RECOMPUTE`, carrying `written`) builds this
1064+
// bridge's answer and carries it on the refusal (#28781's carrier), so
1065+
// the `create_record` tool answers the create it is.
1066+
let stale: SummaryRecomputeRefusal | undefined;
1067+
let written: Record<string, unknown> | undefined;
1068+
try {
1069+
written = (await engine.insert(object, data, { context })) as Record<string, unknown> | undefined;
1070+
} catch (e) {
1071+
stale = staleSummaryRefusalOrThrow(e);
1072+
written = stale.written as Record<string, unknown> | undefined;
1073+
}
10641074
const record = { ...data, ...(written ?? {}) };
10651075
// [#8497] `written` is the engine's WRITE result, which since #7823 keeps
10661076
// the stored row whole — flagged columns included. This is a generic data
@@ -1072,7 +1082,9 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge
10721082
// is absent for a package's boot-registered object on an `environmentId`
10731083
// kernel, and with no schema the helper masks and omits nothing.
10741084
omitInternalFieldsFromWriteResponse(await readObjectDeclaration(reads, object), record);
1075-
return { object, id: record.id, record };
1085+
const answer = { object, id: record.id, record };
1086+
if (stale) throw withEarnedAnswer(stale, answer);
1087+
return answer;
10761088
},
10771089

10781090
async update(object, id, data) {

0 commit comments

Comments
 (0)