Skip to content

Commit 43fc500

Browse files
fix(service-automation): get_record names id in the projection it hands the engine, so output.id is the row id whatever fields names (#22355)
Fixes #22344 Clause-②: no A `get_record` node's single-row (`findOne`) branch answers `output: { record, id, object }`, and a later node reads that `id` as `{read.id}`. The node handed the author's `fields` to the engine unchanged, and the SQL drivers return exactly the columns a projection names, so a projection without `id` answered `output.id` undefined with no error. The branch now names `id` in the projection it hands the engine. ## Measured first: the defect is real on `main` (H1) Measured on `origin/main` `35afb1587` plus the red-first test commit `4abddae9a`, through the real `ObjectQL` (`ObjectKernel` + `ObjectQLPlugin`) and the registered `get_record` executor, `runAs: 'system'`, one row `{ title: 'alpha', status: 'open' }`: | driver | `fields` | `output.id` | `record` keys | |:--|:--|:--|:--| | `driver-sql` (better-sqlite3 `:memory:`) | `['title']` | `undefined` | `[title]` | | `driver-sql` | absent | the row id | all 10 columns, `id` included | | `driver-memory` | `['title']` | the row id | `[id, title]` | | `driver-memory` | absent | the row id | all 5 columns | The memory row is the contrast: its `projectFields` adds `id` to every projection itself, so the defect showed on the SQL drivers only. The flow-level shape the card names was red too: `start → get_record(fields: ['title']) → update_record(filter: { id: '{read.id}' })` was refused with `update_record: refusing to run — 1 filter condition(s) resolved to nothing and were dropped from the query: {read.id} (at id)`. ## Where `id` is added (H2) `projectionNamingId(fields)` in `builtin/crud-nodes.ts` returns the author's `fields` with `id` appended, and the `findOne` branch hands that to `serveFamilyRead` as the node's projection: - a projection that already names `id` is handed over as written, so `id` is never doubled; - `fields` absent or `[]` is returned unchanged, so a whole-row read stays a whole-row read and no projection is invented; - the `find` branch (`limit > 1`) does not call it and is byte-unchanged. Placement, measured: adding `id` before `serveFamilyRead` (shipped) and adding it inside the projection callback (`fields: projectionNamingId(projection)`, a one-off mutation, not committed) give the same answers on both paths, 23/23 across the three `get_record` suites at `f6e3ac4ba`. On the stored-metadata path the family projection only adds and then drops `type`, and `id` survives either way. Before `serveFamilyRead` is the shipped form so that the family door sees the node's whole projection as the node's own, instead of a column it does not know about arriving after it decided. ## `record` carries `id` when the author did not name it (H3) The record the node serves, and binds to its `outputVariable`, carries the `id` the engine returned beside the named columns. The reasons: - the engine returned it, and taking it back off would be a second projection layer over the engine's answer; - `{rec.id}` through the `outputVariable` then agrees with `{read.id}`; stripping it would leave the same defect on the variable spelling, which an author is as likely to write; - every other single-row read already carries it: an unprojected read on every driver, and every projected read on `driver-memory` (table above). Two existing pins asserted the exact column set on the single-row branch, and each now expects the named columns plus `id`: - `get-record-stored-metadata-family.integration.test.ts`: the body-without-type projection now serves `[checksum, id, metadata, name]`, and `type` is still taken back off; - `get-record-formula-projection.integration.test.ts`, which landed with PR #22337 while this branch was open (merged in from `origin/main` `54c3ce10c`): the `one` branch cases now include `id: 'line_1'`, and the `list` branch cases are unchanged. That PR's engine-level trimming is untouched. Its `fields` projection is now handed to the engine with `id` named, which the trim honours. ## Pins (H4), `get-record-id-projection.integration.test.ts` - control: the SQL driver returns exactly the named columns (`{ title: 'alpha' }`); - `fields` omitting `id`: `output.id` is the row id, `record` is `{ id, title }`, and the engine was handed `['title', 'id']` (red on `main`); - `fields` naming `id`: handed as written (`['id', 'title']`), so `id` appears once; - control, no `fields` and `fields: []`: no projection is invented, and `output` equals `{ record: WHOLE_ROW, id, object }` with the engine's own whole-row answer as `WHOLE_ROW`; - flow level: `update_record` filtering on `{read.id}` after a narrowed `get_record` closes exactly that row, and `rec` is `{ id, title }` (red on `main`). ## Ablation, one-off, after the fix was committed (no permanent mutation file) Each leg was run through `scripts/ablation-replace.mjs` in wrap mode. Each anchor hit once, the mutation was verified on disk (anchor count and blob hash), and the restore was proven: the blob equals `HEAD` (`94641f32745d`) and `git diff HEAD` is empty, with a belt `trap` on `EXIT INT TERM`. The subject is imported from source (`./crud-nodes.js`), so no `dist/` leg applies. Final run at `f6e3ac4ba` over the three `get_record` suites: | leg | mutation | result | |:--|:--|:--| | M1 | `return [...fields, 'id']` replaced by `return fields` (fix reverted) | 7 red: omit-id, flow `{read.id}`, family projection, and the 4 formula `one` cases | | M2 | the `fields.includes('id')` guard dropped | 1 red: id-named, handed `['id', 'title', 'id']` | | M3b | the `fields.length === 0` guard dropped | 1 red: empty control, handed `['id']` | | M4 | absent `fields` answered `['id']` | 6 red: no-fields control plus 5 whole-row reads in the family suite | | H2-alt | `id` added inside the callback instead | 23/23 green (a placement measurement, not an ablation) | The first M3 attempt at `d6ccba323` was a no-op. Its replacement text was a substring of its anchor, so `ablation-replace` refused it (replacement count 1 to 1) and never ran the tests. It was re-anchored as M3b and run. ## Verification - `pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2` at `f6e3ac4ba`: 178 files / 2177 tests passed. At `d6ccba323`, before the merge, it was 177 / 2169. - `pnpm --filter @objectstack/service-automation run typecheck` at `f6e3ac4ba`: `tsc --noEmit` clean. `check:test-typecheck` OK, 0 files / 0 errors; `tsconfig.test.json` includes `src/**/*`, so the new test is compiled. - `f68b7d55c` changes only the changeset's wording against `f6e3ac4ba` (`git diff --stat`: one file, `.changeset/`). - Targeted eslint (`--no-inline-config`, `--format json`) on the 4 changed `.ts` files: 4 files, 0 errors, 0 warnings. All 4 fall under the `packages/**/*.{ts,…}` config objects (`--print-config` resolves each). The config enables no type-aware linting (no `parserOptions.project`, per `eslint.config.mjs`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. - Gate families: 64 families derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) at `f68b7d55c`; all 64 run there and all 64 exited 0. `--ran` reconciliation: `64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3)`. The first pass at `d6ccba323` read `check:dual-build-cjs-loads` as PREREQUISITE NOT MET (no workspace `dist/`). After this package was built it passed: `106 published require entry point(s) across 66 package(s) load`. CI's own jobs were not run locally: Test Core, Dogfood, Temporal, Build Core, the workspace type-check lanes, and the roster and wide-population families that `dispatch-gates` names outside its 64. ## Acceptance notes - `GetRecordConfigSchema.fields` (spec) and the descriptor's `configSchema` describe `fields` as "only these fields are read (default: all)". On the single-row branch the served record now also carries `id`. `packages/spec` is outside this card, and the descriptor carries the same sentence as the spec, so neither was edited. A wording follow-up would name `id` there. Owner: none; `domain:services` seat to decide. - The `find` branch (`limit > 1`) declares no top-level `id`. Its rows still carry only the named columns on the SQL drivers when `fields` omits `id`. That is the author's projection, and this PR leaves it unchanged by the card's scope. - `driver-mongodb` was not measured, because the package's tests do not depend on it. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f80f17 commit 43fc500

5 files changed

Lines changed: 243 additions & 5 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a `get_record` whose `fields` leave out `id` answers the row's id as its `id` output on the SQL drivers
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** On its single-row branch (no `limit` above 1) a `get_record` node answers `output: { record, id, object }`, and a later node reads that `id` as `{read.id}` (for a node whose id is `read`). The node handed the author's `fields` to the engine unchanged, and the SQL drivers return exactly the columns a projection names. So a `get_record` with `fields: ['title']` read a row with no `id` column, and `{read.id}` was empty with no error. A later `update_record` filtering on it was refused for a filter condition that resolved to nothing, and any other reference read nothing.
10+
- **What it does now.** That branch names `id` in the projection it hands the engine, so `{read.id}` is the row's id whatever `fields` names, on every driver. The record the node serves, and binds to its `outputVariable`, carries that `id` beside the columns the author named, so the variable's `id` reads the same value. The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) are read the same way: the served row is the named columns plus `id`.
11+
- **Unchanged.** A `get_record` with no `fields`, or an empty list, still reads the whole row. A projection that already names `id` is handed over as written. The multi-row branch (`limit` above 1) answers `records` only and is untouched.

‎packages/services/service-automation/src/builtin/crud-nodes.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,32 @@ async function serveFamilyRead<A>(
278278
return answer;
279279
}
280280

281+
/**
282+
* [#22344] The projection `get_record`'s single-row (`findOne`) branch hands
283+
* the engine: the author's `fields`, with `id` named.
284+
*
285+
* That branch answers `output.id` from the row it read, and a later node reads
286+
* it as `{<node>.id}`. The SQL drivers return exactly the columns a projection
287+
* names, so the author's `fields` handed over unchanged read a row with no
288+
* `id` whenever they left it out: `output.id` was `undefined`, with no error.
289+
* Naming `id` here makes the declared output independent of the author's
290+
* projection, on every driver and on the stored-metadata family path alike
291+
* ({@link serveFamilyRead} takes this projection as the node's own).
292+
*
293+
* `record` then carries the `id` the engine returned, so `{var.id}` through the
294+
* node's `outputVariable` agrees with `{<node>.id}`; it is not taken back off,
295+
* which would be a second projection layer over the engine's answer.
296+
*
297+
* Only a non-empty projection is touched: no `fields`, or an empty list, is a
298+
* whole-row read already, and a projection that names `id` is handed over as
299+
* written. The `find` branch (`limit > 1`) answers rows only, with no
300+
* top-level `id`, and does not come through here.
301+
*/
302+
function projectionNamingId(fields: string[] | undefined): string[] | undefined {
303+
if (!fields || fields.length === 0 || fields.includes('id')) return fields;
304+
return [...fields, 'id'];
305+
}
306+
281307
/**
282308
* [#21623] Refuse a node whose filter EVALUATES the stored-metadata family's
283309
* body or content hash, the way the generic data door refuses the same filter.
@@ -498,7 +524,9 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
498524
metrics: { selected: Array.isArray(records) ? records.length : 0 },
499525
};
500526
}
501-
const record = await serveFamilyRead(data, objectName, fields, (projection) =>
527+
// [#22344] `output.id` is read off the row, so the
528+
// projection the engine is handed names `id`.
529+
const record = await serveFamilyRead(data, objectName, projectionNamingId(fields), (projection) =>
502530
data.findOne(objectName, { where: filter, fields: projection, context: dataCtx }));
503531
if (outputVariable) variables.set(outputVariable, record);
504532
return {

‎packages/services/service-automation/src/builtin/get-record-formula-projection.integration.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
* both run identities. The reference is the same node with the formula left
1717
* out of `fields`: the formula read must serve that row plus the formula.
1818
*
19+
* [#22344] The one-row branch also names `id` in the projection it hands the
20+
* engine (its `output.id` is read off the row), so the row it serves carries
21+
* `id` beside the named fields; the list branch serves the named fields only.
22+
*
1923
* Composition: the real stack the other `*.integration.test.ts` files in this
2024
* package boot — `ObjectKernel`, `ObjectQLPlugin`, `driver-sql` on
2125
* better-sqlite3 `:memory:`, and the real `AutomationServicePlugin`.
@@ -129,16 +133,19 @@ describe('flow get_record serves exactly its `fields` when one is a formula (#22
129133

130134
const CASES: Array<[RunAs, Branch]> = [['system', 'one'], ['system', 'list'], ['user', 'one'], ['user', 'list']];
131135

136+
/** The one-row branch's `id` (#22344); the list branch adds nothing. */
137+
const idOf = (branch: Branch): Record<string, unknown> => (branch === 'one' ? { id: 'line_1' } : {});
138+
132139
it.each(CASES)('runAs %s, %s: the formula read serves the named fields plus the formula, nothing else', async (runAs, branch) => {
133140
const row = await served(runAs, branch, ['name', 'total_price']);
134-
expect(Object.keys(row).sort()).toEqual(['name', 'total_price']);
141+
expect(Object.keys(row).sort()).toEqual([...Object.keys(idOf(branch)), 'name', 'total_price']);
135142
// 3 × 7 — computed from two columns the node never named.
136143
expect(row.total_price).toBe(21);
137144
});
138145

139146
it.each(CASES)('runAs %s, %s: it equals the same read without the formula, plus the formula value', async (runAs, branch) => {
140147
const without = await served(runAs, branch, ['name', 'quantity']);
141-
expect(without, 'control: a projection with no formula is the named columns').toStrictEqual({ name: 'Widget', quantity: 3 });
148+
expect(without, 'control: a projection with no formula is the named columns').toStrictEqual({ ...idOf(branch), name: 'Widget', quantity: 3 });
142149
const withFormula = await served(runAs, branch, ['name', 'quantity', 'total_price']);
143150
expect(withFormula).toStrictEqual({ ...without, total_price: 21 });
144151
});
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A `get_record` node answers `output: { record, id, object }` on its
5+
* single-row (`findOne`) branch, and a later node reads that `id` as
6+
* `{<node>.id}`. The `id` must be the row's id whatever the author's `fields`
7+
* projection names.
8+
*
9+
* The SQL drivers return exactly the columns a projection names. The node used
10+
* to hand the author's `fields` to the engine unchanged, so a projection
11+
* without `id` read the row with no `id` column: `output.id` was `undefined`,
12+
* and a downstream `update_record` filtering on `{read.id}` was refused for an
13+
* empty template. The node now names `id` in the projection it hands the
14+
* engine on that branch, and only there:
15+
* - a projection that already names `id` is handed over as written (no
16+
* second `id`);
17+
* - no projection, or an empty one, stays a whole-row read (no projection is
18+
* invented);
19+
* - the `find` branch (`limit > 1`) declares no top-level `id` and is not
20+
* touched.
21+
*
22+
* `record` carries the `id` the engine returned, so `{rec.id}` through the
23+
* node's `outputVariable` agrees with `{read.id}`; the row's other unnamed
24+
* columns stay out.
25+
*
26+
* Composition: the real stack the other `*.integration.test.ts` files in this
27+
* package boot: `ObjectKernel`, `ObjectQLPlugin`, `driver-sql` on
28+
* better-sqlite3 `:memory:`, and the real `AutomationServicePlugin` for the
29+
* flow-level case. The node-level cases run the registered `get_record`
30+
* executor itself over the same engine, behind a recorder that keeps the
31+
* projection each read handed it.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { ObjectKernel } from '@objectstack/core';
36+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
37+
import { SqlDriver } from '@objectstack/driver-sql';
38+
import { AutomationServicePlugin } from '../plugin.js';
39+
import { AutomationEngine } from '../engine.js';
40+
import { registerCrudNodes } from './crud-nodes.js';
41+
42+
const SYS = { isSystem: true } as const;
43+
44+
const OBJECT = {
45+
name: 'pin_get_record_id',
46+
label: 'Pin get_record id',
47+
fields: {
48+
title: { name: 'title', label: 'Title', type: 'text' },
49+
status: { name: 'status', label: 'Status', type: 'text' },
50+
},
51+
};
52+
53+
function makeLogger(): any {
54+
const l: any = { info() {}, warn() {}, error() {}, debug() {}, trace() {}, fatal() {} };
55+
l.child = () => l;
56+
return l;
57+
}
58+
59+
type Executor = { execute: (node: any, variables: Map<string, unknown>, context: any) => Promise<any> };
60+
61+
describe('get_record answers the row id as output.id whatever its fields projection names', () => {
62+
let kernel: ObjectKernel;
63+
let ql: ObjectQL;
64+
let automation: AutomationEngine;
65+
let rowId: string;
66+
/** The projection each engine read was handed, in order. */
67+
const handed: Array<{ method: string; fields: unknown }> = [];
68+
let getRecord: Executor;
69+
70+
beforeAll(async () => {
71+
kernel = new ObjectKernel({ logger: { level: 'fatal' } });
72+
await kernel.use(new ObjectQLPlugin());
73+
await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' }));
74+
await kernel.bootstrap();
75+
ql = kernel.getService<ObjectQL>('objectql');
76+
automation = kernel.getService<AutomationEngine>('automation');
77+
78+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
79+
await driver.connect();
80+
ql.registerDriver(driver, true);
81+
ql.registry.registerObject(OBJECT as any, 'pin-get-record-id', 'pin-get-record-id');
82+
await ql.syncSchemas();
83+
84+
const row: any = await ql.insert(OBJECT.name, { title: 'alpha', status: 'open' }, { context: SYS });
85+
rowId = row.id;
86+
expect(typeof rowId, 'control: the engine assigned the row an id').toBe('string');
87+
88+
// The executor itself, over the same engine: a recorder in front of the
89+
// engine keeps the projection each read was handed.
90+
const recorder = new Proxy(ql as any, {
91+
get(target, key) {
92+
const value = Reflect.get(target, key, target);
93+
if (key === 'findOne' || key === 'find') {
94+
return (object: string, query: any) => {
95+
handed.push({ method: key, fields: Array.isArray(query?.fields) ? [...query.fields] : query?.fields });
96+
return value.call(target, object, query);
97+
};
98+
}
99+
return typeof value === 'function' ? value.bind(target) : value;
100+
},
101+
});
102+
const executors = new Map<string, Executor>();
103+
const capture = new Proxy(new AutomationEngine(makeLogger()), {
104+
get(target, key, receiver) {
105+
if (key === 'registerNodeExecutor') return (e: any) => { executors.set(e.type, e); };
106+
return Reflect.get(target, key, receiver);
107+
},
108+
});
109+
registerCrudNodes(capture as AutomationEngine, { logger: makeLogger(), getService: (n: string) => (n === 'data' ? recorder : undefined) } as any);
110+
getRecord = executors.get('get_record')!;
111+
expect(getRecord, 'control: the get_record executor was registered').toBeDefined();
112+
}, 60_000);
113+
114+
afterAll(async () => {
115+
try { await kernel?.shutdown(); } catch { /* best-effort teardown */ }
116+
});
117+
118+
/** Run the node once and return its result and the projection the engine was handed. */
119+
async function readOne(config: Record<string, unknown>): Promise<{ result: any; fields: unknown }> {
120+
handed.length = 0;
121+
const result = await getRecord.execute(
122+
{ id: 'read', type: 'get_record', config: { objectName: OBJECT.name, filter: { title: 'alpha' }, ...config } },
123+
new Map(),
124+
{ runAs: 'system', flowName: 'pin_get_record_id' },
125+
);
126+
expect(result.success, `the read failed: ${JSON.stringify(result)}`).toBe(true);
127+
expect(handed, 'the node reads through findOne exactly once').toHaveLength(1);
128+
expect(handed[0].method).toBe('findOne');
129+
return { result, fields: handed[0].fields };
130+
}
131+
132+
it('control: the SQL driver returns exactly the columns a projection names, so a projection without id reads no id', async () => {
133+
const direct: any = await ql.findOne(OBJECT.name, { where: { title: 'alpha' }, fields: ['title'], context: SYS });
134+
expect(direct).toEqual({ title: 'alpha' });
135+
});
136+
137+
it('fields that omit id: output.id is the row id, and record carries it beside the named columns', async () => {
138+
const { result, fields } = await readOne({ fields: ['title'] });
139+
expect(result.output.id).toBe(rowId);
140+
expect(result.output.record).toEqual({ id: rowId, title: 'alpha' });
141+
expect(fields).toEqual(['title', 'id']);
142+
});
143+
144+
it('fields that name id: handed to the engine as written, with id once', async () => {
145+
const { result, fields } = await readOne({ fields: ['id', 'title'] });
146+
expect(fields).toEqual(['id', 'title']);
147+
expect(result.output.id).toBe(rowId);
148+
expect(result.output.record).toEqual({ id: rowId, title: 'alpha' });
149+
});
150+
151+
for (const [label, config] of [['no fields', {}], ['empty fields', { fields: [] }]] as const) {
152+
it(`control, ${label}: the whole row is read and the output is the engine's own answer`, async () => {
153+
const whole: any = await ql.findOne(OBJECT.name, { where: { title: 'alpha' }, context: SYS });
154+
expect(whole.status, 'control: a whole-row read carries the unnamed columns').toBe('open');
155+
const { result, fields } = await readOne(config);
156+
expect(fields, 'no projection is invented').toEqual((config as { fields?: unknown }).fields);
157+
expect(result.output).toEqual({ record: whole, id: rowId, object: OBJECT.name });
158+
});
159+
}
160+
161+
it('a downstream update_record filtering on {read.id} updates the row the narrowed get_record read', async () => {
162+
await ql.insert(OBJECT.name, { title: 'beta', status: 'open' }, { context: SYS });
163+
const def = {
164+
name: 'pin_get_record_id_flow', label: 'pin_get_record_id_flow', type: 'autolaunched', runAs: 'system',
165+
variables: [{ name: 'rec', type: 'object', isOutput: true }],
166+
nodes: [
167+
{ id: 'start', type: 'start', label: 'Start' },
168+
{ id: 'read', type: 'get_record', label: 'Read', config: { objectName: OBJECT.name, filter: { title: 'beta' }, fields: ['title'], outputVariable: 'rec' } },
169+
{ id: 'close', type: 'update_record', label: 'Close', config: { objectName: OBJECT.name, filter: { id: '{read.id}' }, fields: { status: 'closed' } } },
170+
{ id: 'end', type: 'end', label: 'End' },
171+
],
172+
edges: [
173+
{ id: 'e1', source: 'start', target: 'read' },
174+
{ id: 'e2', source: 'read', target: 'close' },
175+
{ id: 'e3', source: 'close', target: 'end' },
176+
],
177+
};
178+
automation.registerFlow(def.name, def as any);
179+
const res: any = await automation.execute(def.name, {});
180+
expect(res.success, `run failed: ${JSON.stringify(res.error ?? res)}`).toBe(true);
181+
const beta: any = await ql.findOne(OBJECT.name, { where: { title: 'beta' }, context: SYS });
182+
expect(beta.status).toBe('closed');
183+
expect(res.output.rec).toEqual({ id: beta.id, title: 'beta' });
184+
const alpha: any = await ql.findOne(OBJECT.name, { where: { title: 'alpha' }, context: SYS });
185+
expect(alpha.status, 'the update reached only the row the node read').toBe('open');
186+
});
187+
});

‎packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,11 +235,16 @@ describe('flow get_record serves the stored-metadata family the way the data doo
235235
expect(JSON.parse(output.metadata)).toEqual(JSON.parse(control.metadata));
236236
});
237237

238-
it('a projection naming the body without its type is served projected, with exactly the columns named', async () => {
238+
// [#22344] The single-row branch names `id` in the projection it hands the
239+
// engine (its `output.id` is read off the row), so the served row carries the
240+
// columns named plus `id`; the `type` column the family's projection adds is
241+
// still taken back off.
242+
it('a projection naming the body without its type is served projected, with exactly the columns named and the row id', async () => {
239243
const control = await doorRow('sys_metadata');
240244
const { output } = await runFlow(familyReadFlow('fam_projection', 'sys_metadata', 'system', 'one', ['name', 'metadata', 'checksum']));
241245
expectServedLikeTheDoor(output, control, 'the run output');
242-
expect(Object.keys(output).sort()).toEqual(['checksum', 'metadata', 'name']);
246+
expect(Object.keys(output).sort()).toEqual(['checksum', 'id', 'metadata', 'name']);
247+
expect(output.id).toBe('meta_pin_flow_ds');
243248
expect(JSON.parse(output.metadata)).toEqual(JSON.parse(control.metadata));
244249
expect(output.checksum).toBe(control.checksum);
245250
});

0 commit comments

Comments
 (0)