Skip to content

Commit d0769bf

Browse files
test(rest): a lookup cell re-imports by a stored computed title through the real import door (#25087)
Fixes #24981 Clause-②: no (pins, and at most one refusal sentence; no accept set moves, since the formula skip already landed in PR #25033) This is the close-out of the card under the maintainer's ruling 6308445871 (letter A). The stored-title half landed in PR #25033 (`dfe60e3a0`). This PR adds the pins the ruling's remedy was missing. It changes no runtime code and ships no changeset: the one new file is a test, and `@objectstack/rest` publishes `dist` only. ## The ruling, items 1 to 4 (6308445871, verbatim) 1. "The import matches a lookup cell against a STORED title only. After the id and the lookup's own `displayField`, `resolveRef` asks the target's declared title field by the one rule (`resolveDisplayField`) when that field is stored — landed in PR #25033. A target whose declared title is a formula keeps answering `reference_not_found`; no scan, no search heuristic, no platform-materialised title column (B-cap, C and D are not taken)." 2. "The producer stores the title it needs to query: a composite title that must round-trip through import is a stored computed field (a non-formula field with an `expression`, written on write — the #24619 shape), the remedy ADR-0079 item 9 already prescribes for sort and search." 3. "The card's second Wanted bullet … is withdrawn by this ruling. The fallback key list and the #22739 exposure order are unchanged." 4. "Wording: whether the `reference_not_found` sentence should say why a formula-titled target cannot match is the dev's to measure against the existing text and declare in the PR; ⛔ no new error code." **The card's second Wanted bullet is withdrawn by ruling 6308445871.** That bullet read: "A formula title is matched by the engine's own record-title resolution, not by a `where` on a column that does not exist." Nothing here matches a formula title. The fallback key list (`name`, `title`, `label`, `full_name`, `email`, `username`) and the exposure order from #22739 are unchanged. ## What this adds `packages/rest/src/import-reference-stored-computed-title.test.ts`, two pins through the REAL import door (`POST /api/v1/data/:object/import`), a REAL ObjectQL engine and a sqlite `:memory:` driver: 1. **The ruling's remedy resolves.** Each target row is written without its title, and the engine computes and stores the title on write. Two lookup cells then link: `Acme (EMEA)` against `nameField: 'display_title'`, a stored `text` field with `expression` `record.name + " (" + record.region + ")"` (not on the fallback list), and `John Smith` against a stored `full_name` (on the list). The test reads the stored ids back. A control row names the same title over the same rows on a twin target whose `display_title` is a `formula`. It answers `reference_not_found`, so the stored declaration is what changes the answer. 2. **A collision is refused.** Two records whose computed titles are both `Acme (EMEA)` answer `reference_ambiguous`, and neither is linked. A third record, `Acme (APAC)`, links in the same import. No change to `import-runner.ts` was needed (the dispatch's H2): the remedy is matched by the rung PR #25033 landed. ## Pins that already held (H1), named and not repeated `packages/core/src/utils/import-runner-reference-title.test.ts` (PR #25033). It was run here at `fe962effe3`: 7 of 7 passed. - `Wren resolves through a stored displayNameField (the card's cell, as authored)`, and the same through `nameField`. Each asserts the candidate order `id`, the lookup's `displayField`, then the title. - `the stored title stores the matched record's id`: one `getMetaItem` per target per import. - `a formula title (xt_contact.full_name)` and `(xt_account.display_title)` `is never asked by where`: `reference_not_found`, no `where` over the formula, no recorded refusal. - `two records sharing a stored title answer the import's ambiguity refusal, and link neither`. - The #22739 pin: a target whose exposure refuses `list` is never read. Beside them, `packages/rest/src/import-integration.test.ts` pins `reference_ambiguous` for a duplicated `displayField` value. The gap: every one of those pins runs over a fake `findData`, or over a target with no computed title. None had a target whose title is a stored computed field, and none had the engine compute a title on write before the import matched it. This PR's file closes that gap and only that gap. It does not repeat what the core suite pins closer to the code (that a formula is never asked by `where`, the candidate order, the metadata read count, the exposure check). The formula row in pin 1 is that file's control leg. ## The `reference_not_found` sentence (ruling item 4): unchanged Measured at `fe962effe3`. A scratch probe, never committed, called `runImport` from `@objectstack/core`'s built `dist/` against a target whose `nameField` is a formula: - Default locale: `Formula Account: no record matches "Acme (EMEA)"`. zh-CN: `Formula Account:未找到与“Acme (EMEA)”匹配的记录`. Asked keys: `id`, `name`, `title`, `label`, `full_name`, `email`, `username` (the formula is skipped). - The sentence is keyed. `import_reference_not_found` lives in `packages/spec/src/system/validation-message.ts`: en `:158`, zh-CN `:206`, ja-JP `:251`, es-ES `:296`. It is rendered by `coerceError` in `packages/core/src/utils/import-coerce.ts` (`:743`, `:759`), not in `import-runner.ts`. **Declared: the sentence stays as it is.** Reasons: 1. It is true for a formula-titled target. No stored record matches the cell, nothing is linked, and the row fails loudly. 2. Saying why would take two contract moves this close-out does not own. First, a reason arm on `RefMatch`, which `@objectstack/core` exports (today `id`, `ambiguous`, `matchedField`), so the coercion layer can learn why the resolver missed. Second, a new keyed sentence in four locales in `packages/spec`. That is no new error code, but it is a public type change and a new message key, for a fact that holds for every row of every import against that target. 3. The fact belongs to the target's metadata. The place that sees it once, before any import runs, is authoring: the ADR-0079 D3 / `validate-record-title` wording card that the seat files under this ruling. The code, the envelope and the i18n key are unchanged. ## Ablation (one leg: the stored title made invisible to `resolveRef`) The pins were committed before the mutation. `@objectstack/rest` resolves `@objectstack/core` through its built `dist/` (an unaliased pair in `KNOWN_UNALIASED_TEST_IMPORTS`), so the leg rebuilt core and proved the mutation was in `dist/` before reading the run. - **Mutation**, through `scripts/ablation-replace.mjs`, in `servesReferenceTarget`. The line `titleField: resolveDisplayField(item),` became a branch that always yields `undefined` and carries the marker `ablation-24981-title-invisible`. Anchor x1 to x0, replacement x0 to x1, blob `d9b249d961d4` to `79957dd60e4f`. Core build OK. `ablation-dist-preflight`: "marker present in 2 built files". - **Predicted before the run:** pin 1 red on `Acme (EMEA)` with `reference_not_found`, while `John Smith` stays linked (its `full_name` is on the fallback list). Pin 2 red: `reference_not_found` in place of `reference_ambiguous`, and `Acme (APAC)` unlinked. - **Observed, as predicted:** `Tests 2 failed (2)`. Pin 1's diff shows only row 1 moved (`ok: false, code: reference_not_found, field: account`), with rows 2 and 3 unchanged. Pin 2's diff shows both rows at `reference_not_found`. - **Restore:** the tool's own proof was "blob == HEAD (`d9b249d961d4`) and `git diff HEAD` is empty". Core was rebuilt, then `ablation-dist-preflight --absent` read "marker absent from all 14 built files" and "working tree clean against HEAD". The pins re-ran green: `Tests 2 passed (2)`. After the full build, the absent reading was taken again and passed. ## Tests and gates (head `fe962effe3`) - Build: the `@objectstack/rest` dependency closure (`turbo run build --filter=@objectstack/rest^...`: "25 successful, 25 total"), then the full workspace (`turbo run build --filter=!@objectstack/docs`: "76 successful, 76 total"). Both ran under `os-verify-lock`, `VERDICT command-exit 0`. - The new pins: `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/import-reference-stored-computed-title.test.ts`: "Tests 2 passed (2)". The core title pins: "Tests 7 passed (7)". - `pnpm --filter @objectstack/rest typecheck`: exit 0. The output reads "check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json; 0 file(s) / 0 error(s)". The new file is in that program: `tsc --listFilesOnly -p tsconfig.test.json` counts it once. - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: "Test Files 310 passed (310)", "Tests 6179 passed | 326 skipped (6505)". - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 54 commands. Each exit code was captured before any pipe, and all 54 exited 0. Two of them first read exit 3 (PREREQUISITE NOT MET), so neither was a measurement. `check:dual-build-cjs-loads` had no full build yet. `check:type-check-debt` did not get the shared lock within its budget. Both were re-run on the same head after the full build. They read "112 published require entry point(s) across 69 package(s) load" and "1 ledger entr(ies) re-measured … none above its recorded number". `--ran` reconciliation: "54 derived famil(ies) accounted for — 54 run, 0 NOT-MEASURED (a DERIVED zero — all 54 recorded an exit code and none of them is 3)". - `check:platform-checklist --changed-from` was not run: no exported symbol was renamed or removed. The repo-wide `pnpm lint`, `test:repo` (the diff touches none of its files) and the full farm are CI's. ## Acceptance notes - **File surface.** The dispatch placed the pins beside the import runner's reference tests in `packages/core`, "through the real import path and a real engine, the way PR #25033's pins do". Measured: PR #25033's pins run over a fake `findData`, and `packages/core` cannot build a real engine (`@objectstack/objectql` depends on `@objectstack/core`; core has no objectql dependency). The ruling's remedy is an engine behaviour (computed on write), so the end-to-end pins sit in `packages/rest`, beside `import-integration.test.ts`, which carries the door's other reference pins. A core-side fake pin of the same remedy would only repeat the core suite's stored-title pin with an `expression` key added, so none was written. - **Changeset.** None. The diff is one test file in a package that publishes `dist` only. `skip-changeset` is the PM's decision. - **Carried, not filed (prior dev reports on the card).** When the declared title misses, the fallback key list can still link a cell by an unrelated column (`VP of Engineering` links a contact by its job `title`). The ruling keeps the list unchanged. - **HotCRM and the ADR-0079 D3 / `validate-record-title` wording** are the seat's cards under the ruling. Neither is touched here. - **Base.** Cut from `72518d20f5`. `origin/main` was merged at `688052e147` before the gate battery and the full build. `main` has since moved 7 commits (`objectql`, `spec` and others). The branch was not merged again, because a second merge would need a second battery and the diff overlaps none of those files. CI and the merge queue rebuild against current `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f97aeb commit d0769bf

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24981] The import matches a lookup cell against a STORED title only
5+
* (ruling 6308445871, letter A: ADR-0079 addendum item 9, extended to the
6+
* import). A `formula` title is computed on read and stored nowhere, so no
7+
* `where` can ask it, and a target titled by one answers
8+
* `reference_not_found`. The remedy the ruling binds is the producer's: a
9+
* composite title that must round-trip through import is a STORED computed
10+
* field, a non-formula field with an `expression`, which the engine computes
11+
* and stores on every write (#24673).
12+
*
13+
* This file pins that remedy end to end, through the REAL import door
14+
* (`POST /api/v1/data/:object/import`), a REAL ObjectQL engine and a REAL
15+
* sqlite `:memory:` driver. The target rows are written WITHOUT their title,
16+
* the engine computes it on write, and the import's title rung (the target's
17+
* `resolveDisplayField`, after the id and the lookup's own `displayField`)
18+
* finds the record by one `where` over the stored column.
19+
*
20+
* Not repeated here, because the runner's own suite pins it closer to the
21+
* code (`packages/core/src/utils/import-runner-reference-title.test.ts`): that
22+
* a formula candidate is never asked by `where`, the exact candidate order,
23+
* the one metadata read per target, and #22739's exposure check. The formula
24+
* row below is only this file's control leg: the same title over the same
25+
* rows, declared as a formula, so the remedy is shown to be what moves the
26+
* verdict.
27+
*/
28+
29+
import { describe, it, expect, afterEach } from 'vitest';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
33+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
34+
import { RestServer } from './rest-server';
35+
36+
const liveEngines: ObjectQL[] = [];
37+
afterEach(async () => {
38+
while (liveEngines.length) {
39+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
40+
}
41+
});
42+
43+
const ACCOUNT_TITLE = { dialect: 'cel', source: 'record.name + " (" + record.region + ")"' };
44+
const CONTACT_TITLE = { dialect: 'cel', source: 'record.first_name + " " + record.last_name' };
45+
46+
/** `Acme (EMEA)`: a stored computed title whose name is NOT on the fallback key list. */
47+
const ACCOUNT = {
48+
name: 'xt_account', label: 'Account', systemFields: false, nameField: 'display_title',
49+
fields: {
50+
id: { name: 'id', type: 'text' as const, primaryKey: true },
51+
name: { name: 'name', type: 'text' as const, label: 'Name' },
52+
region: { name: 'region', type: 'text' as const, label: 'Region' },
53+
display_title: { name: 'display_title', type: 'text' as const, label: 'Display Title', expression: ACCOUNT_TITLE },
54+
},
55+
};
56+
57+
/** The control: the same title over the same rows, declared as a formula. */
58+
const FORMULA_ACCOUNT = {
59+
...ACCOUNT,
60+
name: 'xt_formula_account', label: 'Formula Account',
61+
fields: {
62+
...ACCOUNT.fields,
63+
display_title: {
64+
name: 'display_title', type: 'formula' as const, label: 'Display Title', returnType: 'text', expression: ACCOUNT_TITLE,
65+
},
66+
},
67+
};
68+
69+
/** `John Smith`: a stored computed title, the shape a contact's composite name takes under the ruling. */
70+
const CONTACT = {
71+
name: 'xt_contact', label: 'Contact', systemFields: false, nameField: 'full_name',
72+
fields: {
73+
id: { name: 'id', type: 'text' as const, primaryKey: true },
74+
title: { name: 'title', type: 'text' as const, label: 'Job Title' },
75+
first_name: { name: 'first_name', type: 'text' as const, label: 'First Name' },
76+
last_name: { name: 'last_name', type: 'text' as const, label: 'Last Name' },
77+
full_name: { name: 'full_name', type: 'text' as const, label: 'Full Name', expression: CONTACT_TITLE },
78+
},
79+
};
80+
81+
/** The imported object: one lookup per target, none with a `displayField` of its own. */
82+
const CASE = {
83+
name: 'xt_case', label: 'Case', systemFields: false,
84+
fields: {
85+
id: { name: 'id', type: 'text' as const, primaryKey: true },
86+
subject: { name: 'subject', type: 'text' as const, label: 'Subject' },
87+
account: { name: 'account', type: 'lookup' as const, label: 'Account', reference: 'xt_account' },
88+
formula_account: { name: 'formula_account', type: 'lookup' as const, label: 'Formula Account', reference: 'xt_formula_account' },
89+
contact: { name: 'contact', type: 'lookup' as const, label: 'Contact', reference: 'xt_contact' },
90+
},
91+
};
92+
93+
function createMockServer() {
94+
const noop = () => {};
95+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
96+
}
97+
98+
function makeRes() {
99+
const res: any = {
100+
write: () => true, end: () => {},
101+
header: () => res,
102+
status: (code: number) => { res._status = code; return res; },
103+
json: (body: any) => { res._json = body; return res; },
104+
};
105+
return res;
106+
}
107+
108+
async function boot() {
109+
const engine = new ObjectQL();
110+
liveEngines.push(engine);
111+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
112+
await engine.init();
113+
for (const o of [ACCOUNT, FORMULA_ACCOUNT, CONTACT, CASE]) engine.registry.registerObject(o as any);
114+
await engine.syncSchemas();
115+
for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
116+
if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any);
117+
}
118+
const protocol = new ObjectStackProtocolImplementation(engine as any);
119+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
120+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
121+
rest.registerRoutes();
122+
const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import');
123+
expect(route).toBeDefined();
124+
const importCases = async (rows: Array<Record<string, unknown>>) => {
125+
const res = makeRes();
126+
await route!.handler({ params: { object: 'xt_case' }, body: { format: 'json', rows } } as any, res);
127+
return res._json;
128+
};
129+
return { engine, importCases };
130+
}
131+
132+
const verdicts = (summary: any) =>
133+
summary.results.map((r: any) => ({ ok: r.ok, code: r.code, field: r.field }));
134+
const OK = { ok: true, code: undefined, field: undefined };
135+
136+
describe('[#24981] the ruling\'s remedy: a stored computed title re-imports through the real door', () => {
137+
it('a title the engine computed on write resolves its lookup cell; the same title as a formula does not', async () => {
138+
const { engine, importCases } = await boot();
139+
// The title is NEVER sent: the engine computes and stores it on write.
140+
await engine.insert('xt_account', { id: 'a1', name: 'Acme', region: 'EMEA' });
141+
await engine.insert('xt_formula_account', { id: 'f1', name: 'Acme', region: 'EMEA' });
142+
await engine.insert('xt_contact', { id: 'c1', title: 'VP of Engineering', first_name: 'John', last_name: 'Smith' });
143+
expect((await engine.findOne('xt_account', { where: { id: 'a1' } }))?.display_title).toBe('Acme (EMEA)');
144+
expect((await engine.findOne('xt_contact', { where: { id: 'c1' } }))?.full_name).toBe('John Smith');
145+
146+
const summary = await importCases([
147+
{ id: 'k1', subject: 'stored, off the fallback list', account: 'Acme (EMEA)' },
148+
{ id: 'k2', subject: 'stored, on the fallback list', contact: 'John Smith' },
149+
{ id: 'k3', subject: 'control: the same title as a formula', formula_account: 'Acme (EMEA)' },
150+
]);
151+
expect(verdicts(summary)).toEqual([OK, OK, { ok: false, code: 'reference_not_found', field: 'formula_account' }]);
152+
expect((await engine.findOne('xt_case', { where: { id: 'k1' } }))?.account).toBe('a1');
153+
expect((await engine.findOne('xt_case', { where: { id: 'k2' } }))?.contact).toBe('c1');
154+
expect(await engine.findOne('xt_case', { where: { id: 'k3' } })).toBeFalsy();
155+
});
156+
157+
it('two records whose computed titles collide answer the import\'s ambiguity refusal, and link neither', async () => {
158+
const { engine, importCases } = await boot();
159+
await engine.insert('xt_account', { id: 'a1', name: 'Acme', region: 'EMEA' });
160+
await engine.insert('xt_account', { id: 'a2', name: 'Acme', region: 'EMEA' });
161+
await engine.insert('xt_account', { id: 'a3', name: 'Acme', region: 'APAC' });
162+
163+
const summary = await importCases([
164+
{ id: 'k1', subject: 'two records titled alike', account: 'Acme (EMEA)' },
165+
{ id: 'k2', subject: 'one record titled so', account: 'Acme (APAC)' },
166+
]);
167+
expect(verdicts(summary)).toEqual([{ ok: false, code: 'reference_ambiguous', field: 'account' }, OK]);
168+
expect(await engine.findOne('xt_case', { where: { id: 'k1' } })).toBeFalsy();
169+
expect((await engine.findOne('xt_case', { where: { id: 'k2' } }))?.account).toBe('a3');
170+
});
171+
});

0 commit comments

Comments
 (0)