diff --git a/.changeset/lookup-picker-reference-only.md b/.changeset/lookup-picker-reference-only.md new file mode 100644 index 0000000000..a290848a68 --- /dev/null +++ b/.changeset/lookup-picker-reference-only.md @@ -0,0 +1,25 @@ +--- +'@objectstack/rest': minor +--- + +**BREAKING (runtime behaviour on a published route).** The public-form lookup-picker route +`GET /forms/:slug/lookup/:field` resolves its target object from the canonical field key +`reference` alone. The three tolerant fallback arms it used to read after it — the +`referenceTo`, `target` and `options.objectName` spellings — are deleted. + +Effect on the wire: a stored object-metadata row whose lookup field carries one of those +three spellings and no `reference` used to answer `200` with rows from the aliased object; it +now answers `500 LOOKUP_TARGET_MISSING`, and the data engine is never called. A field +carrying `reference` is unaffected, including a partially-migrated row carrying a legacy +spelling beside it. `publicPicker.object` on the form is still the explicit override and is +still read first. + +No migration is prescribed, and none is owed. `FieldSchema` is a `strictObject` that refuses +`relatedTo`, `referenceTo`, `target`, `targetObject` and `lookupObject` by name, answering +with a rename hint naming the canonical key, so no authoring path can produce such a row; a +census across both trees found no producer and no relation field carrying any of them, with +positive controls; and the maintainer ruled on 2026-09-09 that no deployment holds rows to +preserve. The spec spelling is the contract, and a stored row spelling the target the old way +is a producer defect rather than a dialect this route accommodates. + + diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index a227915a07..ef26e5779b 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -257,7 +257,7 @@ sections: [{ | `displayFields` | Fields projected into each result row (plus `id`); the visitor's `q` is `contains`-matched against the **first** entry. At most 5; omitted → `['name']`. | | `maxResults` | Rows per request, integer 1–50 (default 20). 50 is a hard server ceiling; there is **no pagination** on this surface (`offset` is pinned to 0), so a leaked endpoint cannot enumerate the table. | | `filter` | Static pre-filter rows (same `{ field, operator, value }` dialect as list-view filters), ANDed ahead of the visitor's search. | -| `object` | The object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object (its `reference`, or a legacy `referenceTo` / `target` / `options.objectName` on a pre-fold stored row). Declare it only to search something other than what the field points at. | +| `object` | The object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object: its `reference` key, and only that key. A stored row spelling the target `referenceTo` / `target` / `options.objectName` is **not** resolved — the route answers `500 LOOKUP_TARGET_MISSING` — because `FieldSchema` accepts no spelling but `reference`. Declare it only to search something other than what the field points at. | Those four keys are the whole block. It admits exactly what the route enforces — an unknown subkey, a 6th display field, or `maxResults: 51` is a **parse diff --git a/packages/rest/src/public-form-lookup-picker.test.ts b/packages/rest/src/public-form-lookup-picker.test.ts index 3ebd42b9f6..367d3a4788 100644 --- a/packages/rest/src/public-form-lookup-picker.test.ts +++ b/packages/rest/src/public-form-lookup-picker.test.ts @@ -355,6 +355,14 @@ describe('#7485 publicPicker.sort is retired — not declarable, and not read', * `500 LOOKUP_TARGET_MISSING`, making `publicPicker.object` de-facto REQUIRED * while the schema and docs present it as optional. * + * ⛔ [#12920] What the fix must NOT be, settled by ruling: the repair was + * never "read the legacy spellings too". The route read `reference` FIRST and + * three legacy spellings after it until 2026-09-09, when the tolerant tail was + * retired — director seat summon #20, decision batch #107 item 5, maintainer + * verbatim 「其他同意」 = option A, executing the 2026-08-30 stance, verbatim + * 「折叠即契约」. This route now reads `reference` and nothing else, and the + * cases below pin that. + * * Every case below omits `object` deliberately: that is the axis under test. * The suite above covers the override branch and must stay that way — between * them the two branches of the resolution are both pinned. @@ -385,41 +393,59 @@ describe('#7486 the picker target resolves from the field definition when `objec // ⛔ [#13137] These are spellings `FieldSchema` REFUSES. There is no fold, // so there is no "pre-fold row" for one of these to be — an earlier version - // of this comment said there was, and #12920 cites that sentence as its - // strongest surviving evidence that stored legacy rows exist. It carries + // of this comment said there was, and #12920 cited that sentence as its + // strongest surviving evidence that stored legacy rows exist. It carried // ZERO observational content about stored data, and neither do these cases. // - // What is true and what these pin: a def spelling the target this way never - // came through `FieldSchema`, and the serving read path replays ADR-0087 - // conversions (`applyConversionsToStoredItem`) without any schema - // validation, so such a def would reach the route verbatim. These cases pin - // what the route DOES with one. ⚠️ Whether any exists is #12920's open - // production census — ⛔ assert nothing here in either direction. - // The fix EXTENDS the chain; one that replaced it would turn these three - // green cases into 500s. + // ⭐ [#12920] RULED, and these cases now pin the ruling. The open + // production census this block used to suspend judgement on — "is a stored + // alias-spelled row reachable in a live deployment?" — was answered by the + // maintainer, not by a scan: NONE to preserve, consistent with the + // 2026-08-27 startup-phase principle (no staged transitions) and with the + // in-tree census (zero producers, zero relation fields spelling the target + // with an alias, positive controls fired). ⇒ the route's four-spelling + // tolerant chain retired; a stored row spelling the target the old way is + // a PRODUCER defect, and this route refuses it like every other consumer. + // + // What is true and unchanged: such a def never came through `FieldSchema`, + // and the serving read path replays ADR-0087 conversions + // (`applyConversionsToStoredItem`) without any schema validation, so it + // WOULD reach the route verbatim. These cases pin what the route does with + // one — refuse it, loudly, with the service never called. + // + // ⚠️ Direction matters: all three are RED against the four-arm chain (it + // answers 200 and searches `sys_user`) and green against the one-key read. + // ⛔ A pin that passed in both states would be no evidence at all. const LEGACY_DEFS: Array<[string, Record]> = [ ['referenceTo', { type: 'lookup', referenceTo: 'sys_user' }], ['target', { type: 'lookup', target: 'sys_user' }], ['options.objectName', { type: 'lookup', options: { objectName: 'sys_user' } }], ]; for (const [spelling, ownerDef] of LEGACY_DEFS) { - it(`a stored row spelling the target the LEGACY way (\`${spelling}\`) still resolves`, async () => { + it(`a stored row spelling the target the LEGACY way (\`${spelling}\`) is NOT resolved`, async () => { const stored = await savedWithoutObject(); const legacyObject = { ...leadObject, fields: { ...leadObject.fields, owner: ownerDef } }; - const { findData, lookup } = routesOver(stored, [], legacyObject); + // The engine is loaded with a row a resolving route WOULD return, + // so the red state is a 200 carrying data, not an empty 200. + const { findData, lookup } = routesOver(stored, [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }], legacyObject); const res = mockRes(); await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - expect(res.statusCode).toBe(200); - expect(findData.mock.calls[0][0].object).toBe('sys_user'); + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('LOOKUP_TARGET_MISSING'); + // ⛔ Both halves. The status alone cannot separate "refused the + // alias" from "resolved it and the search came back empty"; only + // the never-called half says the target was never resolved. + expect(findData).not.toHaveBeenCalled(); }); } - it('the canonical `reference` WINS over a legacy spelling on the same def', async () => { - // Head-of-chain, not merely present-in-chain: a row carrying both (a - // partially-migrated def) must follow the canonical key. Appending - // `reference` to the tail of the chain would pass every case above and - // fail only this one. + it('the canonical `reference` still resolves on a def that ALSO carries a legacy spelling', async () => { + // A partially-migrated def: the canonical key present, an alias beside + // it. `reference` is read and the alias is not consulted at all — + // neither to shadow it nor to break it. Before #12920 this pinned + // head-of-chain ORDER; with one key left it pins that narrowing the + // read did not make a canonical def collateral damage. const stored = await savedWithoutObject(); const bothObject = { ...leadObject, @@ -468,9 +494,11 @@ describe('#7486 the picker target resolves from the field definition when `objec * schemas in one day, which is what this file's prose used to say too. * * ⛔ SCOPE: this pins the SPEC's behaviour and nothing else. It asserts - * nothing about whether any stored row spells a target the legacy way — that - * is #12920's open production census, unanswerable from here, and ⛔ no - * assertion below may be cited as evidence in either direction. + * nothing about whether any stored row spells a target the legacy way. That + * question — #12920's production census — was closed by the maintainer on + * 2026-09-09 ("none to preserve"), ⛔ not from here: it was never answerable + * from a schema pin, and no assertion below may be cited as evidence for it in + * either direction. */ describe('#13137 `FieldSchema` REFUSES the legacy target spellings, it does not fold them', () => { /** `type` is the only required key on `FieldSchema`; everything else is the axis under test. */ diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 28b3714c64..a858fef3a4 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -10658,8 +10658,8 @@ export class RestServer { // `publicPicker.object` override, fall back to the // field def on the parent object. const p = await this.resolveProtocol(environmentId, req); - let referenceTo: string | undefined = picker.object; - if (!referenceTo && typeof (p as any).getMetaItems === 'function') { + let referenceObject: string | undefined = picker.object; + if (!referenceObject && typeof (p as any).getMetaItems === 'function') { try { const objectsRequest: TransportScopedMetaRequest = { type: 'object', @@ -10669,58 +10669,45 @@ export class RestServer { const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; const obj = items.find((o: any) => o?.name === match.object); const def = obj?.fields?.[fieldName]; - // [#7486] `reference` FIRST — it is the canonical - // key on `FieldSchema`, and the ONLY spelling that - // schema accepts. Reading only the legacy spellings - // meant a well-formed object schema carried NONE of - // them, the chain resolved `undefined`, and the - // route answered 500 — making `publicPicker.object` - // de-facto required while the schema and docs - // present it as an optional override. + // [#7486] Resolve the target from the canonical key — and, since + // [#12920], from it ALONE. `reference` is the spelling `FieldSchema` + // accepts, so it is the only spelling a field def can legitimately + // carry. // - // ⛔ [#13137] `data/field.zod.ts` does NOT fold the - // legacy spellings onto `reference`. An earlier - // version of this comment said it did, and that - // sentence is precisely what invited consumers to - // be lenient. Its `aliases` table is a RENAME HINT - // ON A REJECTED KEY, not a normaliser: - // `strictObject` consults `aliases` only from the - // `unrecognized_keys` path (the semantics are - // stated in `spec/src/shared/strict-object.ts`), so - // `relatedTo` / `referenceTo` / `target` / - // `targetObject` / `lookupObject` are REFUSED by - // `FieldSchema` — answered with *"Did you mean - // `referenceTo` → `reference`?"* and never - // rewritten. Pinned three ways (accept / - // alias-refusal-with-hint / unknown-key-refusal - // -without-hint) in - // `public-form-lookup-picker.test.ts`. - // ⇒ ⛔ this chain is NOT licence to be lenient - // anywhere else: nothing upstream folds for you, - // and a producer emitting a legacy spelling emits a - // document the spec refuses by name. + // ⛔ [#12920] This read used to be a four-spelling tolerant chain + // (`reference ?? referenceTo ?? target ?? options.objectName`). It was + // RETIRED by ruling — director seat summon #20, decision batch #107 + // item 5, 2026-09-09, maintainer verbatim 「其他同意」 = option A — + // executing the stance recorded 2026-08-30, verbatim 「折叠即契约」: + // the spec spelling IS the contract, and a stored row spelling the + // target the old way is a PRODUCER defect, not a shape this route + // accommodates. The prerequisite that had held execution — whether any + // live deployment holds alias-spelled rows — was answered by the + // maintainer: none to preserve. // - // The tail below reads exactly three spellings — - // `referenceTo`, `target`, `options.objectName` — - // which is NOT the spec's five-entry hint list: - // only the first two appear on it, and - // `options.objectName` appears on no list at all. - // They can reach here only on a STORED row that - // never went through `FieldSchema`, which is - // possible because the serving read path replays - // ADR-0087 conversions - // (`applyConversionsToStoredItem`) and performs no - // schema validation. ⚠️ Whether such a row is still - // reachable in production is #12920's OPEN census — - // ⛔ do not widen this chain here, and do not narrow - // it here either; #12920 decides its fate. - referenceTo = def?.reference - ?? def?.referenceTo - ?? def?.target - ?? def?.options?.objectName; + // Wire-visible consequence, deliberate: a stored def spelling the + // target `referenceTo` / `target` / `options.objectName` now resolves + // NOTHING here, and the route answers `500 LOOKUP_TARGET_MISSING` + // instead of searching the aliased object. Pinned, in both directions, + // in `public-form-lookup-picker.test.ts`. + // + // ⛔ Do not re-widen this read, here or in any sibling consumer — + // widening it back is how the platform came to answer the same + // question differently per consumer. Nothing upstream folds for you: + // [#13137] `data/field.zod.ts`'s `aliases` table is a RENAME HINT ON A + // REJECTED KEY, not a normaliser (`strictObject` consults it solely + // from the `unrecognized_keys` path — the semantics are stated in + // `spec/src/shared/strict-object.ts`), so `relatedTo` / `referenceTo` / + // `target` / `targetObject` / `lookupObject` are REFUSED by + // `FieldSchema`, answered with *"Did you mean `referenceTo` → + // `reference`?"*, and never rewritten. The one place an alias IS + // tolerated is the ADR-0087 conversion layer (`fieldReferenceToAlias`), + // replayed on stored-row rehydration — declared, tested and removable + // on a schedule, which a `??` arm here never was. + referenceObject = def?.reference; } catch {/* ignore */} } - if (!referenceTo) { + if (!referenceObject) { res.status(500).json({ code: 'LOOKUP_TARGET_MISSING', error: `Could not resolve referenced object for "${fieldName}"`, @@ -10768,7 +10755,7 @@ export class RestServer { }; const pickerRequest: ServerScopedDataRequest = { - object: referenceTo, + object: referenceObject, // [#16337] Canonical QueryAST: `filters` → `where`, // `select` → `fields`, `sort` → `orderBy`. The normalizer // folds each of those aliases onto exactly these keys and @@ -10786,7 +10773,7 @@ export class RestServer { // all; that the value is now a filter the ingress ACCEPTS // is measured end-to-end, not asserted by the type. query: { - object: referenceTo, + object: referenceObject, limit: maxResults, offset: 0, where: filters,