Skip to content

Commit dbe2a93

Browse files
fix(objectql): a write answers every stored declared column the driver did not echo as null, as the next read does (#29538) (#29858)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8N41nMAvUJQvgDEc6wx2yb6)_ Fixes #29538 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 新建与按 id 更新的返回记录,对驱动没有回显的已存储声明列补 `null`,和紧接着的一次读取完全一致;本次写入自己带了值(或更新前已存值)的列不补 `null`,驱动没回显就照旧缺省。 - **为什么改:** mongo / memory 驱动上,新建或编辑后的返回体缺少未写的字段,而下一次 GET 同一行却把它们答成 `null`;客户端拿到同一条记录的两个不同版本,公式字段在返回体里还会报错为 `null`。 - **风险与回滚:** 只改返回体,不改存储内容,也不改读路径;区分「缺键」和「`null`」的读者现在读到 `null`(driver-sql 一直如此)。回滚即 revert 本 PR。 - **先看:** `packages/objectql/src/engine.ts` 插入结果处的 `storedColumnsLeftUnwritten` 调用(批量按行配对 payload);同文件按 id 更新回读处;`packages/objectql/src/declared-read-columns.ts` 新增的 `storedColumnsLeftUnwritten`。 - **两分钟验证:** `pnpm --filter @objectstack/objectql exec vitest run src/write-answer-declared-columns.test.ts`,预期 32 个用例全过。 - **需要你做的:** 无,席位落地。 ## What changes - **The write answer is the next read's record.** `ObjectQL.insert` (single, batch, and each `insertMany` outcome) and the by-id `update` readback now answer every stored declared column the driver did not echo as `null`, reusing #29408's `selectedStoredColumns` + `withSelectedColumnsAnswered` (no copy). Placed right after the existing `withDeclaredColumnsOnly` shaping, before the formulas and the after-hooks, so a formula over an unwritten column evaluates against `null` as the read does, and the security middleware's field mask still deletes an FLS-refused key afterwards. Every door on those two verbs answers alike: `POST /data/:object`, `PATCH /data/:object/:id`, `createManyData`, `updateManyData`, `batchData` create / update / both upsert arms. - **Never a `null` the row does not hold** (new in this round, `storedColumnsLeftUnwritten` in `declared-read-columns.ts`). A column the insert payload, or the PATCH and then the raw stored row read before it, carries a non-null value for is stored, so it is left out of the fill: a driver that stores it without echoing it leaves it absent, as before. A PATCH `null` stays answerable. On driver-sql, driver-memory and driver-mongodb the echo is the stored row, so this changes nothing there. It is what the consumer sweep asked for: `trigger-record-change`'s flow record overlays the write result on the input (`{ ...previous, ...input.data, ...after }`, #1872), and a fabricated `null` there overwrote a written multi-lookup value (`multilookup-context.test.ts` red, below). - **Unchanged:** what a write stores; the read path #29408 landed; a predicate update's affected-row count; the read's exclusions (formula, unprovisioned injected, federated-unprovisioned, undeclared, FLS-refused stay absent). - Changeset `.changeset/29538-write-answer-declared-column-null.md`: `@objectstack/objectql` patch, both declaration lines. ## Why After #29408 every read answers an unwritten declared column `null` on every driver, but the write answer still came straight from the driver: driver-sql echoed `null`, driver-mongodb and driver-memory echoed only the stored keys. A create on mongo or memory answered `{ title: 'x' }` and the very next `GET` answered `{ title: 'x', note: null }`; a formula over `note` answered `null` (a fault) in the create response and its value on the `GET`. Ruling-ref 6584622486 (contract review on #29528, open_question 2 answered B: this follow-up). ## Tests - New pins: `objectql/src/write-answer-declared-columns.test.ts` (sparse driver, 12 write paths `toStrictEqual` the next `findOne`, formula control; plus 4 cases for a driver that stores a column without echoing it: insert, batch per row, update with a prior stored value, PATCH clearing to `null`), `runtime/src/declared-column-null-on-write.test.ts` (driver-sql and a sparse stub with `SecurityPlugin`: FLS-refused column absent from the member's insert / batch / update answers, `null` for system), `driver-mongodb/src/mongodb-declared-column-null-on-write.test.ts` (fake `Db`; the single-row arms' collection declares only `insertOne` / `updateOne` / `findOne`, the batch arm's only `insertMany` / `find`). - **Forced test edits (six assertions in four files, plus the hook reads in a fifth).** Each read "left unset" off the write answer on a sparse stub; each now asserts the answer is `null` AND the stored row has no key. Per file, the production caller that reads the write answer for that key today: - `engine-default-value-tokens.test.ts` (`owner`, a lookup whose `current_user` default resolves to nothing): **none.** `grep` for `.owner === undefined`, `.owner !== undefined`, `'owner' in` over `packages/`, `examples/`, `ui/` non-test source: 0 hits. The readers of a write answer that do exist (record-change flow record, which materialises missing declared fields to `null` anyway; plugin-audit; the REST doors) treat absent and `null` alike. - `engine-lookup-referential-integrity.test.ts` (`recorded_by`, a readonly lookup stripped before the reference check): **none.** Test-local field; no production code reads a stripped field off a write answer as `undefined` (same grep shape, 0 hits). - `engine-select-option-default.test.ts` ×3 (`status`, `tags`): **none.** `grep (record|row|result|created|data).(status|tags) (===|!==) undefined`: one hit, `examples/app-todo/src/objects/task.hook.ts:86`, which reads `data.status` = `ctx.input.data` (the PATCH payload), not the write answer. - `engine-write-formula-hydration.test.ts` (the KEY SET of the `createData` record): **none.** `grep Object.(keys|entries)((created|updated|inserted|saved|written)…)`: one hit, `core/src/utils/import-runner.ts:799`, which iterates the import's payload, not the answer; `ui/`: 0 hits. The assertion now also `toStrictEqual`s the next read. - `protocol-save-notice-answer.test.ts` (`say_after` on `ctx.result` in after-hooks): **none.** Test-local field; `grep ctx.result` with `undefined` / `in` / `hasOwn` / `Object.keys` in production source: only `runtime/src/sandbox/quickjs-runner.ts:672`, which tests the whole `ctx.result`, not a key. - The one real consumer that relied on the old answer was not one of these six: `trigger-record-change/src/multilookup-context.test.ts`. It was fixed upstream in the engine (above), not by editing the test. <details><summary>Ablations (each: mutation on disk, objectql rebuilt, marker proven in dist/, restore proven by blob == HEAD and the marker absent from dist/)</summary> - A1 insert fill removed → 14 objectql, 2 driver-mongodb and 2 runtime reds. A2 update fill removed → 11, 1 and 2. A3 insert fill moved after the security mask → the FLS pin reds on both runtime drivers. A4 the same for update → the FLS pin reds again. (A1–A4 measured by the previous dev on `1cb3f6a3`, hand-off 6591637215.) - A5 `storedColumnsLeftUnwritten` made a no-op (fill everything) → 3 of the 4 new non-echo pins red + `trigger-record-change` `multilookup-context` red; the PATCH-clears-to-`null` pin stays green, as a control should. - A6 the write fill emptied → all 3 driver-mongodb arms red. (A first A6 attempt failed the DTS build and measured nothing; re-run with a typed mutation.) </details> ## Acceptance notes - Engine-level write results keep `internal: true` fields whole (the A-prime ruling), so an engine-level write answer still differs from an engine `findOne` there on every driver. Pre-existing, out of scope; the REST doors strip them. - Two gate reds on this PR's own new test code were instrument readings, fixed in the tests without touching any gate or ledger: `check:engine-double-contract` counted the mongo pin's native `Collection` fake as an engine double (`find` + `insertMany` are in `ENGINE_SIBLINGS`, `insertMany` in `ENGINE_ONLY_MEMBERS`), and `check:where-matcher` lifted the `driver` literal into the sparse fake's matcher because its refusal text said "sparse driver". Both are reported to the seat. ## Verification All on head `77b6b65dd` (base `origin/main` merged at `143b91915`), unless noted. Every heavy run went through `scripts/pm/os-verify-lock.sh`; each line quotes its `VERDICT` exit. - Touched packages: `@objectstack/objectql` test 545/545 files, 10792 passed; `driver-mongodb` 35 passed + 6 skipped files, 728 passed / 194 skipped; `runtime` 535/535 files, 8562 passed / 19 skipped; `trigger-record-change` 12/12, 121 passed. Typecheck (incl. `check:test-typecheck`) exit 0 for objectql, runtime, driver-mongodb. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 63 derived; each run, exit codes recorded; `--ran` → "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED". `pnpm lint` exit 0. - Dogfood battery on `94b04574b` (the later commits are test-only edits and a merge of `main`): closure build exit 0; `pnpm --filter @objectstack/dogfood test` exit 0, 399 passed + 3 skipped files, 3297 passed / 28 skipped, 0 failed. <details><summary>Downstream consumer sweep (prefix direction: packages whose tests boot a real ObjectQL), on f847848 after the engine fix, all exit 0</summary> objectql 544 files · runtime · driver-mongodb · driver-memory · rest · plugin-security · plugin-audit · trigger-record-change 12/12 · metadata-protocol 308 + 4 skipped · service-analytics 208 · plugin-auth 215 · plugin-approvals 110 · service-messaging 63 · service-storage 71 · service-settings 67 · plugin-sharing 63 · service-automation 262 · service-datasource 70 · plugin-webhooks 25 · client 54 · organizations 18 · example-showcase 45 · plugin-email 48 · service-mailbox 10 · driver-odata 9 · plugin-pinyin-search 4 · connector-mcp 9 · cloud-connection 67 · example-crm 7 · cli 566 + 1 skipped. The first pass (on `3597f584`, before the carried-value rule) had 22 of those 23 sweep packages green and `trigger-record-change` red 1 (`multilookup-context.test.ts`: `expected null to be 'ch_1'`), which the engine change above fixes. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The `## 维护者速读` block is the fleet's maintainer summary, in Chinese by convention.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXRug3R6WZAjtFuAoS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dde5df4 commit dbe2a93

11 files changed

Lines changed: 739 additions & 13 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a write answers every stored declared column the driver did not echo as `null`, so the insert and update answer is the record the next read returns, on every driver (#29538)
6+
7+
Clause-②: no — nothing a request may send is refused or newly admitted, and nothing a write stores changes; measured on base and head through the engine and the protocol doors on SQLite, driver-memory and driver-mongodb (fake `Db`), every write was accepted on both. An insert or by-id update answer gains a `null` key for a stored declared column the driver did not echo, and on driver-mongodb and driver-memory a `formula` over such a column in that answer now evaluates against that `null`, as the next read and driver-sql already did.
8+
Metadata-protocol: none
9+
10+
- **Before.** After #29408, `find` and `findOne` answer an unwritten declared column `null` on every driver, but the write answer still came straight from the driver. driver-sql echoed `null` (its table has the column); driver-mongodb and driver-memory echoed only the keys the write stored. So a create on mongo or memory answered `{ title: 'x' }` and the very next `GET` of the same row answered `{ title: 'x', note: null }`, and a formula reading `note` answered `null` (a fault) in the create response and its value on the `GET`.
11+
- **Now.** The engine answers the insert result (single, batch, and each `insertMany` outcome) and the by-id update readback on every stored declared column, `null` where the driver echoed no key, before the formulas and the `afterInsert` / `afterUpdate` hooks. Every door on those two verbs answers alike: `POST /data/:object`, `PATCH /data/:object/:id`, `createManyData`, `updateManyData`, and `batchData` create / update / both upsert arms.
12+
- **Never a `null` the row does not hold.** A column the insert payload, or the PATCH and then the stored row before it, carries a value for is stored, so a driver that stores it without echoing it leaves it absent from the answer, as before, rather than answered `null`. A reader that layers the payload under the answer (the record-change flow's `record`, which overlays the write result on the input) keeps the value it wrote.
13+
- **Unchanged.** What a write stores. A predicate update still answers its affected-row count. A column field-level security refuses the writer stays absent (the security mask still deletes it), and so do a name outside the declared set, an injected column a federated (`external`) object has no storage for, and a `created_at` / `updated_at` a federated object neither declares nor has injected — the read's own exclusions.
14+
- **Who sees it.** A client of an object served by driver-mongodb or driver-memory that diffs a create or update response against its own state, or renders it: a field the save never mentioned now arrives in that response as `null`, as it does on the next read, instead of appearing only on the next read. In-process readers of the write result see the same: an `afterInsert` / `afterUpdate` hook, a `data.record.created` / `data.record.updated` event payload and the webhooks that deliver it. A reader that told a missing key from `null` (`'x' in row`, `has(record.x)`) now reads what driver-sql already answered; test emptiness with `record.x != null`. A `formula` over an unwritten field in a write response on those two drivers changes value: it used to answer `null` with a fault, and now answers what the next read answers (`record.qty != null ? record.qty * 2 : -1` was `null`, now `-1`).
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29538] driver-mongodb through the engine: a write answers the record the
5+
* next read answers — a declared column the write never touched is `null` in
6+
* the insert and update answer, as on the following `findOne` (#29408).
7+
*
8+
* This driver stores no key for a field given no value (`withoutUndefinedOwnKeys`
9+
* at the insert doors), its `create` / `bulkCreate` echo the document they
10+
* inserted, and its `update` re-reads the document. So the row each write
11+
* hands the engine is sparse. Measured on `origin/main` before the fix with
12+
* this same harness: every write answer below carried no `note` / `qty` key
13+
* and answered the formula `doubled` as `null` (a fault on the missing
14+
* `qty`), while the next `findOne` answered `note: null`, `qty: null` and
15+
* `doubled: -1`.
16+
*
17+
* Runs without a server, on the fake `Db` of
18+
* `mongodb-declared-column-null-on-read.test.ts`, with the write calls the
19+
* engine's insert and by-id update doors reach. The fake keeps each document
20+
* as inserted and answers a projection as a real mongod does — the named keys
21+
* the document HAS. The real-server suites are gated on `mongodb-memory-server`
22+
* (#5517).
23+
*/
24+
25+
import { describe, it, expect, beforeEach } from 'vitest';
26+
import { ObjectQL } from '@objectstack/objectql';
27+
import { MongoDBDriver } from './mongodb-driver.js';
28+
29+
type Doc = Record<string, unknown>;
30+
31+
/**
32+
* A fake `Db`: one store of documents kept as inserted, served through a
33+
* collection that declares ONLY the native `Collection` members the path under
34+
* test calls. Two shapes, one per path: the single-row doors (`create`,
35+
* `update`, `findOne`) reach `insertOne` / `updateOne` / `findOne`; the batch
36+
* door (`bulkCreate`) reaches `insertMany`, and its next read is a `find`.
37+
*/
38+
function fakeStore() {
39+
const docs: Doc[] = [];
40+
const project = (doc: Doc, projection: Record<string, number> | undefined): Doc => {
41+
const keep = Object.keys(projection ?? {}).filter((k) => projection![k] === 1);
42+
const out: Doc = {};
43+
for (const [k, v] of Object.entries(doc)) {
44+
if (k === '_id' && projection?._id === 0) continue;
45+
if (keep.length > 0 && !keep.includes(k)) continue;
46+
out[k] = v;
47+
}
48+
return out;
49+
};
50+
// Equality on plain keys only — the writes and reads below address a row
51+
// by `id` alone. A combinator or any other operator is REFUSED, never read
52+
// as a field name.
53+
const matches = (doc: Doc, filter: Doc | undefined): boolean =>
54+
Object.entries(filter ?? {}).every(([k, v]) => {
55+
if (k.startsWith('$')) throw new Error(`fake Db: filter operator ${k} is not implemented`);
56+
if (v !== null && typeof v === 'object') {
57+
const ops = Object.keys(v as Doc);
58+
if (ops.length !== 1 || ops[0] !== '$eq') throw new Error(`fake Db: operator ${ops.join(',')} is not implemented`);
59+
return doc[k] === (v as Doc).$eq;
60+
}
61+
return doc[k] === v;
62+
});
63+
const push = (doc: Doc) => { docs.push({ _id: `oid_${docs.length}`, ...doc }); };
64+
const pick = (filter: Doc) => docs.find((x) => matches(x, filter));
65+
const pickAll = (filter: Doc, options?: { projection?: Record<string, number> }) =>
66+
docs.filter((x) => matches(x, filter)).map((d) => project(d, options?.projection));
67+
return { docs, push, pick, pickAll, project };
68+
}
69+
70+
/** The single-row doors: `create`, `update` and `findOne`. */
71+
function singleRowDb() {
72+
const store = fakeStore();
73+
const collection = {
74+
insertOne: async (doc: Doc) => { store.push(doc); return {}; },
75+
updateOne: async (filter: Doc, update: { $set?: Doc }) => {
76+
const updateOps = Object.keys(update);
77+
if (updateOps.length !== 1 || updateOps[0] !== '$set') throw new Error(`fake Db: update ${updateOps.join(',')} is not implemented`);
78+
const d = store.pick(filter);
79+
if (d) Object.assign(d, update.$set);
80+
return { matchedCount: d ? 1 : 0 };
81+
},
82+
findOne: async (filter: Doc, options: { projection?: Record<string, number> }) => {
83+
const d = store.pick(filter);
84+
return d ? store.project(d, options?.projection) : null;
85+
},
86+
};
87+
return { db: { collection: () => collection }, docs: store.docs };
88+
}
89+
90+
/** The batch door: `bulkCreate`, and `find` for the next read. */
91+
function batchDb() {
92+
const store = fakeStore();
93+
const collection = {
94+
insertMany: async (many: Doc[]) => { for (const doc of many) store.push(doc); return {}; },
95+
find: (filter: Doc, options: { projection?: Record<string, number> }) => ({
96+
toArray: async () => store.pickAll(filter, options),
97+
}),
98+
};
99+
return { db: { collection: () => collection }, docs: store.docs };
100+
}
101+
102+
const OBJECT = 'nk_mongo_write_item';
103+
104+
const own = (row: unknown, key: string): boolean =>
105+
row != null && typeof row === 'object' && Object.prototype.hasOwnProperty.call(row, key);
106+
107+
describe('[#29538] driver-mongodb: a write answers the record the next read answers', () => {
108+
let engine: ObjectQL;
109+
let docs: Doc[];
110+
111+
const boot = async (fake: { db: unknown; docs: Doc[] }) => {
112+
const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/unused' });
113+
docs = fake.docs;
114+
(driver as any).db = fake.db;
115+
(driver as any).connect = async () => {};
116+
(driver as any).disconnect = async () => {};
117+
engine = new ObjectQL();
118+
engine.registerDriver(driver as never, true);
119+
await engine.init();
120+
engine.registry.registerObject({
121+
name: OBJECT,
122+
label: 'Item',
123+
fields: {
124+
name: { type: 'text' },
125+
note: { type: 'text' },
126+
qty: { type: 'number' },
127+
doubled: { type: 'formula', expression: { dialect: 'cel', source: 'record.qty != null ? record.qty * 2 : -1' } },
128+
},
129+
} as never);
130+
};
131+
132+
beforeEach(async () => { await boot(singleRowDb()); });
133+
134+
const expectAnswerIsNextRead = async (answer: Doc, next?: Doc) => {
135+
expect(answer).toStrictEqual(next ?? await engine.findOne(OBJECT, { where: { id: answer.id } }));
136+
expect(own(answer, 'note')).toBe(true);
137+
expect(answer.note).toBeNull();
138+
expect(own(answer, 'qty')).toBe(true);
139+
expect(answer.qty).toBeNull();
140+
// FORMULA CONTROL: evaluated against the `null`, as the read evaluates it.
141+
expect(answer.doubled).toBe(-1);
142+
};
143+
144+
it('insert: the answer deep-equals the next findOne', async () => {
145+
const answer = await engine.insert(OBJECT, { name: 'Ada' }) as Doc;
146+
expect(own(docs[0], 'note')).toBe(false);
147+
await expectAnswerIsNextRead(answer);
148+
});
149+
150+
it('batch insert (bulkCreate): every answered row deep-equals its next read', async () => {
151+
await boot(batchDb());
152+
const answers = await engine.insert(OBJECT, [{ name: 'Ada' }, { name: 'Bo' }]) as Doc[];
153+
expect(answers).toHaveLength(2);
154+
expect(own(docs[0], 'note')).toBe(false);
155+
for (const answer of answers) {
156+
const [next] = await engine.find(OBJECT, { where: { id: answer.id } }) as Doc[];
157+
await expectAnswerIsNextRead(answer, next);
158+
}
159+
});
160+
161+
it('update by id: the answer deep-equals the next findOne', async () => {
162+
const created = await engine.insert(OBJECT, { name: 'Ada' }) as Doc;
163+
const answer = await engine.update(OBJECT, { id: created.id, name: 'Ada L.' }) as Doc;
164+
expect(answer.name).toBe('Ada L.');
165+
await expectAnswerIsNextRead(answer);
166+
// The stored document still carries no key for the column: only the
167+
// answer is shaped, never what the write stores.
168+
expect(own(docs[0], 'note')).toBe(false);
169+
});
170+
});

‎packages/objectql/src/declared-read-columns.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,13 @@ export function rowsWithDeclaredColumnsOnly<T>(rows: T[], declared: ReadonlySet<
159159
* engine's own widening (formula inputs, a file field's owner key, each cut
160160
* back off the answer before the verb returns) are what count;
161161
* - no projection (a whole-record GET, a list read) selects every declared
162-
* column.
162+
* column;
163+
* - [#29538] a write's answer (the insert result, single or batch, and the
164+
* by-id update readback) names no projection either, so it is answered on
165+
* the whole stored record: the write answer equals the next read of the row.
166+
* Less the columns the write itself carried a value for
167+
* ({@link storedColumnsLeftUnwritten}): those are stored, so a driver that
168+
* did not echo one leaves it absent rather than answered `null`.
163169
*
164170
* ## What stays absent, and why
165171
*
@@ -233,6 +239,38 @@ export function withSelectedColumnsAnswered<T>(row: T, selected: ReadonlySet<str
233239
return (shaped ?? row) as unknown as T;
234240
}
235241

242+
/**
243+
* [#29538] The {@link selectedStoredColumns} a write's answer may fill with
244+
* `null`: those minus every column the write's images say holds a value. A
245+
* write image is what the engine knows the row stores without the driver's
246+
* echo, newest first — the payload it handed the driver and, on an update, the
247+
* stored row it read before the write. The first image that names a column
248+
* decides it: a non-null value IS stored, so a driver that stored it and did
249+
* not echo it leaves it absent from the answer (unknown), never answered
250+
* `null` (a value the row does not hold) — a reader that layers the payload
251+
* under the answer (`{ ...input, ...result }`) would otherwise lose the value
252+
* it wrote. A `null` there (a PATCH clearing a value the prior row held, too)
253+
* is a stored `null` and stays answerable; an `undefined` names nothing. Same
254+
* set reference when nothing is removed.
255+
*/
256+
export function storedColumnsLeftUnwritten(
257+
selected: ReadonlySet<string> | undefined,
258+
...writeImages: unknown[]
259+
): ReadonlySet<string> | undefined {
260+
if (!selected || selected.size === 0) return selected;
261+
const images = writeImages.filter(
262+
(image): image is Record<string, unknown> => !!image && typeof image === 'object' && !Array.isArray(image),
263+
);
264+
let left: Set<string> | undefined;
265+
for (const name of selected) {
266+
const decider = images.find((image) => image[name] !== undefined);
267+
if (decider === undefined || decider[name] === null) continue;
268+
left ??= new Set(selected);
269+
left.delete(name);
270+
}
271+
return left ?? selected;
272+
}
273+
236274
/** {@link withSelectedColumnsAnswered} over a driver's result page. */
237275
export function rowsWithSelectedColumnsAnswered<T>(rows: T[], selected: ReadonlySet<string> | undefined): T[] {
238276
if (!selected || selected.size === 0) return rows;

‎packages/objectql/src/engine-default-value-tokens.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,11 @@ describe('[#4560] the `current_user` defaultValue token is engine-owned', () =>
103103
// decision a column DEFAULT used to override, writing the literal string
104104
// `current_user` into a lookup('sys_user') column (#4560).
105105
const row: any = await engine.insert('tok_doc', { title: 'B' }, { context: { isSystem: true } } as any);
106-
expect(row.owner).toBeUndefined();
107-
expect(row.owner).not.toBe('current_user');
106+
// [#29538] The answer is the next read's record: a column nothing was
107+
// stored in reads `null`. "Unset" is the STORED row's own: no key at all.
108+
expect(row.owner).toBeNull();
109+
const stored = await engine.getDriverForObject('tok_doc')!.findOne('tok_doc', { where: { id: row.id } });
110+
expect(stored).not.toHaveProperty('owner');
108111
});
109112

110113
it('an explicit null is treated as "not supplied" and still resolves the token (#2706)', async () => {

‎packages/objectql/src/engine-lookup-referential-integrity.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,11 @@ describe('[#4441] a lookup id that resolves to nothing is refused', () => {
370370
const row: any = await engine.insert(
371371
'ref_audit_note', { note: 'n', recorded_by: 'ps_does_not_exist' }, { context: userCtx } as any,
372372
);
373-
expect(row.recorded_by, 'stripped before the reference check, not refused by it').toBeUndefined();
373+
// [#29538] The answer reads the unstored column `null`, as the next read
374+
// does; the strip is the STORED row's own: no key at all.
375+
expect(row.recorded_by, 'stripped before the reference check, not refused by it').toBeNull();
376+
const stored = await engine.getDriverForObject('ref_audit_note')!.findOne('ref_audit_note', { where: { id: row.id } });
377+
expect(stored, 'stripped before the reference check, not refused by it').not.toHaveProperty('recorded_by');
374378
});
375379

376380
it('…and the issue\'s own fields are NOT readonly, so they stay enforced', () => {

‎packages/objectql/src/engine-select-option-default.test.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ function makeStubDriver() {
7272

7373
const sys = { context: { isSystem: true } } as any;
7474

75+
/** The row as the stub driver stored it — what the write persisted, before any engine shaping. */
76+
const storedRow = (engine: ObjectQL, object: string, id: unknown) =>
77+
engine.getDriverForObject(object)!.findOne(object, { where: { id } });
78+
7579
describe('[#7246] the option marked `default: true` is the field default', () => {
7680
let engine: ObjectQL;
7781

@@ -132,7 +136,10 @@ describe('[#7246] the option marked `default: true` is the field default', () =>
132136
},
133137
} as any, 'test.issue7246');
134138
const row: any = await engine.insert('opt_unmarked', { title: 'D' }, sys);
135-
expect(row.status).toBeUndefined();
139+
// [#29538] The answer reads the unstored column `null`, as the next read
140+
// does; "left alone" is the STORED row's own: no key at all.
141+
expect(row.status).toBeNull();
142+
expect(await storedRow(engine, 'opt_unmarked', row.id)).not.toHaveProperty('status');
136143
});
137144
});
138145

@@ -348,8 +355,11 @@ describe('[#7246] the default follows the FIELD shape, not the option count', ()
348355
{ label: 'Quick', value: 'quick' },
349356
]) as any, 'test.issue7246');
350357
const row: any = await engine.insert('opt_valueless', {}, sys);
351-
expect(row.tags).toBeUndefined();
352-
expect(row.tags).not.toBeNull();
358+
// [#29538] The answer reads the unstored column `null`, as the next read
359+
// does; "left unset" (no value, not even a stored `null`) is the STORED
360+
// row's own: no key at all.
361+
expect(row.tags).toBeNull();
362+
expect(await storedRow(engine, 'opt_valueless', row.id)).not.toHaveProperty('tags');
353363
});
354364
});
355365

@@ -400,6 +410,8 @@ describe('[#7246] the lint heuristic and the engine agree BY CONSTRUCTION', () =
400410
},
401411
} as any, 'test.issue7246');
402412
const row: any = await engine.insert('opt_alias', {}, sys);
403-
expect(row.status).toBeUndefined();
413+
// [#29538] The answer reads the unstored column `null`; the STORED row has no key.
414+
expect(row.status).toBeNull();
415+
expect(await storedRow(engine, 'opt_alias', row.id)).not.toHaveProperty('status');
404416
});
405417
});

‎packages/objectql/src/engine-write-formula-hydration.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,8 +557,12 @@ describe('#5504 — cost threshold: same gate the read path uses', () => {
557557
it('an object declaring NO formula evaluates nothing on write', async () => {
558558
const created = await rig.protocol.createData({ object: 'wf_plain', data: { name: 'nothing derived' } });
559559
expect(evaluate).not.toHaveBeenCalled();
560-
// …and the response carries exactly the stored keys — no phantom columns.
561-
expect(Object.keys(created.record as Rec).sort()).toEqual(['id', 'name']);
560+
// …and the response carries exactly the stored values — no phantom columns.
561+
// [#29538] Every other stored declared column is answered `null`, as the
562+
// next read answers it: the response IS that read's record.
563+
const record = created.record as Rec;
564+
expect(Object.keys(record).filter((k) => record[k] !== null).sort()).toEqual(['id', 'name']);
565+
expect(record).toStrictEqual(await rig.engine.findOne('wf_plain', { where: { id: record.id } }));
562566
});
563567

564568
it('an object declaring formulas evaluates once per formula field per row', async () => {

0 commit comments

Comments
 (0)