From f0116dad90ae0e6bc4711c0cf5951fda7a38cffc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 15:52:58 +0000 Subject: [PATCH 1/3] fix(spec,lint): a formula field in searchableFields is refused loudly (#6674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4254 closed the fail-open on the unknown-name axis. The same shape survived one axis over, on names that are perfectly real: the declared branch of `resolveSearchFieldResolution` filtered by EXISTENCE only, so a `formula` field declared in `searchableFields` entered the allowed set — and the #4254 ingress gate, which reads that same set, accepted it for exactly that reason. A formula value is computed on read and no driver materializes a column for it, so the `$contains` the engine expands `$search` into has nothing to scan. Measured: 0 rows on driver-memory, 0 rows WITH NO ERROR on driver-sql. The declaration read as search coverage and delivered none. - spec (the deciding face): the declared branch filters on existence AND scannability; new `SEARCH_VIRTUAL_TYPES` / `isVirtualSearchField` are the one judgment resolution, gate and linter share. The resolution stays non-throwing — internal callers never pass an ingress, which is why #4254 put the loudness at the ingress. - metadata-protocol: 400 INVALID_FIELD under its own reason, split out before the declared/auto branch because both of those messages are wrong for it. - lint: a build error on the object's own set as well as a view's narrowing, under the existing `searchable-field-unsearchable` rule. The storage-not-taste carve-out is kept and pinned by controls in all three packages: a declared `json` / `lookup` column is still executed, because it has a column and CAN match. Corpus sweep of objectstack + objectui + cloud: zero authored declarations affected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 --- .../searchable-fields-formula-refused.md | 68 +++++++ content/docs/data-modeling/schema-design.mdx | 10 +- content/docs/references/data/object.mdx | 2 +- content/docs/ui/views.mdx | 1 + .../src/validate-searchable-fields.test.ts | 110 +++++++++++ .../lint/src/validate-searchable-fields.ts | 60 +++++- packages/metadata-protocol/src/protocol.ts | 44 ++++- .../src/query-expression-conformance.test.ts | 185 ++++++++++++++++++ packages/spec/src/data/object.zod.ts | 2 +- packages/spec/src/data/search-fields.test.ts | 132 +++++++++++++ packages/spec/src/data/search-fields.ts | 96 ++++++++- skills/objectstack-data/SKILL.md | 8 +- skills/objectstack-ui/SKILL.md | 1 + 13 files changed, 690 insertions(+), 29 deletions(-) create mode 100644 .changeset/searchable-fields-formula-refused.md diff --git a/.changeset/searchable-fields-formula-refused.md b/.changeset/searchable-fields-formula-refused.md new file mode 100644 index 0000000000..d9d1bdfc2b --- /dev/null +++ b/.changeset/searchable-fields-formula-refused.md @@ -0,0 +1,68 @@ +--- +"@objectstack/metadata-protocol": patch +"@objectstack/spec": patch +"@objectstack/lint": patch +--- + +fix(spec,lint): a virtual `formula` field in `searchableFields` is refused loudly, not admitted verbatim (#6674) + +#4254 closed the fail-open on the unknown-name axis: a `$searchFields` entry the +engine would not scan is `400 INVALID_FIELD`, never a silently widened search. +The same shape survived one axis over, on names that are perfectly real. + +The declared branch of `resolveSearchFieldResolution` filtered entries by +EXISTENCE only, so a `formula` field declared in `searchableFields` entered the +allowed set — and the ingress gate, which reads that same set, accepted it for +exactly that reason. Measured on `origin/main`: + +``` +AUTO: {"allowed":["name","project_name"],"source":"auto"} formula excluded +DECL-FORMULA: {"allowed":["name","project_name_formula"],"source":"declared"} admitted verbatim +?search=Apollo&searchFields=project_name_formula -> 200, 0 rows silent +``` + +Zero rows is the defect. A formula value is computed on read and no driver +materializes a column for it (`driver-sql` `fieldHasColumn`, driver-turso's +"Virtual — no column"), so the `$contains` the engine expands `$search` into has +nothing to scan: 0 rows on driver-memory (the property is absent from the stored +row) and 0 rows WITH NO ERROR on driver-sql/better-sqlite3. The declaration read +as search coverage and delivered none. + +- **`@objectstack/spec` — the deciding face.** The declared branch now filters on + existence AND scannability: an entry naming a virtual field is not admitted. + New exports `SEARCH_VIRTUAL_TYPES` (exactly `formula`, pinned) and + `isVirtualSearchField` — one judgment, so the resolution, the gate and the + linter cannot drift about which types have a column. The resolution itself + stays non-throwing: it is consulted on every search by internal callers that + never pass an ingress, which is why #4254 put the loudness at the ingress. +- **`@objectstack/metadata-protocol` — `400 INVALID_FIELD` with its own reason.** + Split out before the declared/auto branch, because both of those messages are + wrong for it: "outside the declared set" is false when the entry IS in the + list, and the auto-default's "declare `searchableFields` to choose the + searchable set" would instruct the author to write the declaration being + refused. The new message names the field, its type, that the value is computed + on read and never stored, and the fix (mirror onto a stored text field). +- **`@objectstack/lint` — a build error at authoring time**, on the object's own + `searchableFields` as well as a view's narrowing, under the existing + `searchable-field-unsearchable` rule (no new rule id). This narrows the + canonical surface, which #4830 had deliberately left existence-only. + +The carve-out that made canonical existence-only is deliberately KEPT and pinned +by controls in all three packages: the dividing line is STORAGE, not search +quality. A `json` or `lookup` column declared in `searchableFields` is still the +author's choice and still executed — a `$contains` over the stored JSON text or +the stored foreign key. Narrow and rarely useful, but a scan that CAN match, so +it is neither a 400 nor a finding. Only "there is no column at all" is refused. + +**Compatibility.** A corpus sweep of this repo plus `objectui` and `cloud` found +ZERO authored `searchableFields` naming a formula-typed field, so nothing in the +tree changes verdict. For an already-published object that does carry one: +loading is unaffected (no schema-parse change — `searchableFields` is still +`z.array(z.string())`, this is a resolution and enforcement rule); a plain +`?search=` keeps returning the SAME rows, because the dropped entry matched none +of them; only a request that NAMES the formula field flips from `200` with no +rows to `400 INVALID_FIELD` — including objectui's list search, which echoes the +declaration verbatim. An object whose `searchableFields` is ENTIRELY formula +entries filters to empty and falls through to the auto-default, exactly as an +all-stale declaration has since #4254; the linter reports the declaration rather +than leaving that swap silent. diff --git a/content/docs/data-modeling/schema-design.mdx b/content/docs/data-modeling/schema-design.mdx index 2e30bed237..9ad5025117 100644 --- a/content/docs/data-modeling/schema-design.mdx +++ b/content/docs/data-modeling/schema-design.mdx @@ -140,9 +140,13 @@ by the auto-default anyway; declare the set explicitly when you want to pin it. `$contains` predicate against one has nothing to scan (the SQL driver would emit a `WHERE` over a column that does not exist). A CEL formula also only reads this record's own fields (`record.`), so it cannot fetch the related title in -the first place. Nothing catches the mistake for you — `searchableFields` admits -any field the object declares, so a formula entry passes both lint and the -ingress gate and then just never matches. +the first place. + +Since #6674 the mistake is **refused rather than silent**: a `formula` entry in +any `searchableFields` — the object's own set included — is an `os validate` +error (`searchable-field-unsearchable`), and a request naming one is `400 +INVALID_FIELD`. It used to clear both and then match nothing, which read as +search coverage and delivered none. **Keeping the mirror fresh.** A mirror is denormalized data, only as current as diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 9f4980b1e6..a4bc434158 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -137,7 +137,7 @@ const result = ApiMethod.parse(data); | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | -| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. | +| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused (#6674) — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be <= sharingModel in openness. | diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 660a7006b4..88f297c51e 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -160,6 +160,7 @@ all. | key omitted, or `searchableFields: []` | clean | scans the object's full allowed set | | a renamed / mistyped column, or a dotted path | `searchable-field-unknown` | `400 INVALID_FIELD` | | a real column outside the allowed set | `searchable-field-unsearchable` | `400 INVALID_FIELD` | +| a virtual `formula` column — no stored column to scan (#6674) | `searchable-field-unsearchable` | `400 INVALID_FIELD` | Both diagnostics are **errors**, not warnings — `os validate` fails the build. The object's own set, and the stored-mirror prescription, are covered under diff --git a/packages/lint/src/validate-searchable-fields.test.ts b/packages/lint/src/validate-searchable-fields.test.ts index 0aa088cc9f..6247e6043a 100644 --- a/packages/lint/src/validate-searchable-fields.test.ts +++ b/packages/lint/src/validate-searchable-fields.test.ts @@ -647,3 +647,113 @@ describe('validateSearchableFields — objectstack-ui SKILL.md parity (#6675)', expect(refused[0].message).toContain("of type 'lookup', which 'search' cannot scan"); }); }); + +/** + * [#6674] A virtual `formula` entry — the one check that runs on the object's + * OWN set as well as on a view's narrowing. + * + * The card's shape: the entry names a real field, so the existence check passes + * it; the runtime's declared branch admitted it verbatim; and the search then + * matched nothing, because a formula value is computed on read and no driver + * materializes a column for it (0 rows on driver-memory, 0 rows WITH NO ERROR on + * driver-sql). Declared coverage, zero delivery — the fail-open #4254 closed on + * the unknown-name axis, surviving on the known-but-virtual one. + */ +describe('[#6674] validateSearchableFields — a virtual formula entry', () => { + const accountFields = { + name: { type: 'text' }, + billing_email: { type: 'email' }, + payload: { type: 'json' }, + account_id: { type: 'lookup', reference: 'crm_account' }, + display_label: { type: 'formula', expression: "record.name + ' · x'" }, + }; + + it("flags it on the OBJECT's own searchableFields — previously clean", () => { + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: accountFields, + searchableFields: ['name', 'display_label'], + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('objects[0].searchableFields[1]'); + expect(findings[0].where).toBe('object "crm_account"'); + expect(findings[0].message).toContain("is a virtual 'formula' field"); + expect(findings[0].message).toContain('computed on read and never stored'); + // The fix is a STORED mirror — the same prescription #6673 put on the + // neighbouring hints, and the only one that can work here. + expect(findings[0].hint).toContain('stored text field'); + expect(findings[0].hint).toContain('400 INVALID_FIELD'); + }); + + it('flags it on a list view narrowing too', () => { + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: accountFields, + listViews: { all: { type: 'grid', searchableFields: ['name', 'display_label'] } }, + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); + expect(findings[0].path).toBe('objects[0].listViews.all.searchableFields[1]'); + expect(findings[0].message).toContain("is a virtual 'formula' field"); + }); + + it('CONTROL — a json or lookup entry on the OBJECT\'s own set stays clean', () => { + // The carve-out #4830 wrote down, deliberately preserved: the runtime's + // declared branch executes those (a `$contains` over the stored JSON text / + // the stored foreign key). Narrow and rarely useful, but a scan that CAN + // match — flagging it would reject metadata the runtime accepts (ADR-0072 + // D1). If this control ever goes red, #6674 has quietly become "the declared + // branch is type-filtered", which it is not. + expect( + validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: accountFields, + searchableFields: ['name', 'payload', 'account_id'], + }, + ], + }), + ).toEqual([]); + }); + + it('CONTROL — a stale entry on the object\'s own set keeps the #4254 message', () => { + const findings = validateSearchableFields({ + objects: [ + { name: 'crm_account', fields: accountFields, searchableFields: ['name', 'gone'] }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNKNOWN); + expect(findings[0].message).toContain('is not a field on object'); + }); + + it('an ALL-virtual declaration is reported, not silently swapped for the auto-default', () => { + // The degenerate case: at runtime the declaration filters to empty and + // resolution falls through to the auto-default, so the object silently + // searches a set the author never wrote. The build error is what stops that + // being invisible. + const findings = validateSearchableFields({ + objects: [ + { name: 'crm_account', fields: accountFields, searchableFields: ['display_label'] }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[0].searchableFields[0]'); + expect(findings[0].message).toContain("is a virtual 'formula' field"); + }); +}); diff --git a/packages/lint/src/validate-searchable-fields.ts b/packages/lint/src/validate-searchable-fields.ts index a1def7249e..22107a3a38 100644 --- a/packages/lint/src/validate-searchable-fields.ts +++ b/packages/lint/src/validate-searchable-fields.ts @@ -60,12 +60,25 @@ * linter, gate and engine cannot drift apart (the same one-source move * #4254 made between gate and engine). * - * The OBJECT's own `searchableFields` stays existence-only: the runtime's - * declared branch filters by existence, never by type, so a json or lookup - * column declared THERE is a choice the engine executes (a `$contains` over - * the raw column), not a 400. Flagging it would reject metadata the runtime - * accepts — the false finding that makes authors stop trusting the linter - * (ADR-0072 D1). + * 3. VIRTUALITY, on EVERY surface — the object's own set included + * (`searchable-field-unsearchable`, #6674): a `formula` entry names a real + * field, so check 1 passes it, and the runtime's declared branch admitted + * it verbatim — but the value is computed on read with no stored column, so + * the scan looks at nothing. Measured 0 rows on driver-memory and 0 rows + * WITH NO ERROR on driver-sql. Since #6674 the spec resolution drops such + * an entry and both enforcement faces refuse it by name. + * + * The OBJECT's own `searchableFields` stays existence-only OTHERWISE, and the + * dividing line is STORAGE, not search quality: the runtime's declared branch + * filters by existence and scannability, never by type taste, so a json or + * lookup column declared THERE is a choice the engine executes (a `$contains` + * over the stored JSON text / the stored foreign key) — narrow, rarely useful, + * but a scan that CAN match, so it is neither a 400 nor a finding. Flagging + * those would reject metadata the runtime accepts — the false finding that + * makes authors stop trusting the linter (ADR-0072 D1). A virtual entry is the + * opposite case: no column exists on any driver, so admitting it is the + * fail-open #4254 closed one axis over, surviving on the known-but-virtual + * axis. * * Three skips keep false positives near zero (ADR-0072 D1): * @@ -93,6 +106,7 @@ import { resolveSearchFieldResolution, + isVirtualSearchField, SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS, @@ -124,8 +138,9 @@ export interface SearchableFieldFinding { * Which runtime judgment applies to the declaration being checked: * * - `'canonical'` — the object's own `searchableFields`. The runtime honors - * any entry that exists (existence-filtered, never type-filtered), so only - * existence is checked. + * any entry that exists and has a stored column to scan (existence- and + * scannability-filtered, never type-filtered), so existence and virtuality + * are checked and nothing else (#6674). * - `'narrowing'` — a list view's `searchableFields` (metadata or react * surface). Clients echo it as the `$searchFields` override, which the * #4254 ingress gate intersects with the object's allowed set — entries the @@ -354,6 +369,35 @@ export function checkSearchableFieldList( continue; } + // ── [#6674] Virtual entries — EVERY surface, canonical included ── + // + // The one check that is not view-level, because the runtime's declared + // branch no longer executes it: a `formula` value is computed on read and + // has no stored column, so the entry can never match wherever it is + // declared. Judged by the spec's own predicate, so linter and runtime + // cannot disagree about which types have a column. + if (isVirtualSearchField(target.fields[name])) { + const vtype = target.fields[name]?.type; + findings.push({ + severity: 'error', + rule: SEARCHABLE_FIELD_UNSEARCHABLE, + where, + path: `${path}[${i}]`, + message: + `${subject} entry "${name}" on object "${objectName}" is a virtual ` + + `'${vtype}' field: its value is computed on read and never stored, so no ` + + `driver materializes a column for 'search' to scan and the entry can never ` + + `match. It reads as search coverage and delivers none — the runtime used to ` + + `admit it verbatim because the declaration named it (#6674).`, + hint: + `Mirror the computed value onto a stored text field on "${objectName}" and ` + + `declare that instead, or drop "${name}". At runtime the ingress gate now ` + + `refuses this entry with 400 INVALID_FIELD, the same answer a stale entry ` + + `gets (#4254).`, + }); + continue; + } + // ── Runtime admissibility (#4830) — view-level narrowings only ── if (!resolution || resolution.allowed.has(name)) continue; // ③ System column outside the allowed set: its runtime metadata is diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index d4876b1a64..f48656a910 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -45,6 +45,7 @@ import { parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf, AggregationFunction, DateGranularity, resolveSearchFieldResolution, SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS, + isVirtualSearchField, RUNTIME_OWNED_FIELD_TYPES, RPC_QUERY_ALIAS_SLOTS, foldQueryAliasSlots, type QueryAliasConflict, type QueryAliasSlot, @@ -5064,11 +5065,16 @@ export class ObjectStackProtocolImplementation implements * export would stop downloading "the unsearched superset … in a file that * looks authoritative". * - * Two rejections, one code, different messages, because the fixes differ - * (the same split the expand axis draws): a name that is no field at all - * is a typo, while a REAL field outside the searchable set needs the - * OBJECT changed — added to a declared `searchableFields`, or declared - * searchable at all when the auto-default excludes its type. The allowed + * Rejections share one code and differ in message, because the fixes + * differ (the same split the expand axis draws): a name that is no field + * at all is a typo, while a REAL field outside the searchable set needs + * the OBJECT changed — added to a declared `searchableFields`, or declared + * searchable at all when the auto-default excludes its type. [#6674] adds + * the one case where changing the OBJECT cannot help either: a VIRTUAL + * (`formula`) field has no stored column on any driver, so declaring it + * searchable is not a narrower search but a scan of nothing — it used to + * clear this gate precisely BECAUSE the object declared it, the fail-open + * shape this axis exists to refuse. The allowed * set itself comes from {@link resolveSearchFieldResolution} in * `@objectstack/spec/data` — the same function the engine's search * expansion consumes — so this gate cannot admit a field the engine would @@ -5140,10 +5146,24 @@ export class ObjectStackProtocolImplementation implements const declaredSet = new Set(Array.isArray(gate.schema?.searchableFields) ? gate.schema.searchableFields : []); const unknown = names.filter((n) => !allowedSet.has(n) && !gate.known.has(n) && !declaredSet.has(n)); const staleDeclared = names.filter((n) => !allowedSet.has(n) && !gate.known.has(n) && declaredSet.has(n)); - const unsearchable = names.filter((n) => !allowedSet.has(n) && gate.known.has(n)); + // [#6674] A VIRTUAL field is its own rejection, split out of + // `unsearchable` before the source branch below rather than after it, + // because BOTH of that branch's messages are wrong for it. The declared + // one ("a field outside it cannot be a search target until it is added + // there") is false — it may already BE in the list, which is exactly the + // shape this closes; the auto one prescribes "declare `searchableFields` + // to choose the searchable set explicitly", which for a formula field is + // an instruction to author the refused declaration. The fix is neither: + // the value has no column anywhere, so it must be mirrored onto a stored + // one. Judged by the same `@objectstack/spec/data` predicate the + // resolution applies, so gate and engine cannot disagree about which + // types have a column. + const virtual = names.filter((n) => !allowedSet.has(n) && gate.known.has(n) && isVirtualSearchField(gate.fields[n])); + const unsearchable = names.filter((n) => !allowedSet.has(n) && gate.known.has(n) && !isVirtualSearchField(gate.fields[n])); const [offenders, reason] = unknown.length > 0 ? [unknown, 'unknown' as const] : staleDeclared.length > 0 ? [staleDeclared, 'stale-declared' as const] + : virtual.length > 0 ? [virtual, 'virtual' as const] : [unsearchable, 'unsearchable' as const]; if (offenders.length === 0) return; const first = offenders[0]; @@ -5154,6 +5174,18 @@ export class ObjectStackProtocolImplementation implements + (offenders.length > 1 ? ` (also: ${offenders.slice(1).join(', ')})` : '') + '. The declaration is stale — searching it can never match, and the engine ' + "silently skipped it. Fix the object's 'searchableFields' to name real fields."; + } else if (reason === 'virtual') { + const vtype = gate.fields[first]?.type ?? 'formula'; + detail = `Field '${first}' on object '${object}' is a virtual '${vtype}' field and cannot be searched` + + (offenders.length > 1 ? ` (also: ${offenders.slice(1).join(', ')})` : '') + + `. Its value is computed on read and never stored, so no driver materializes a ` + + `column for 'search' to scan and the entry can never match — measured as 0 rows, ` + + 'with no error, on both the in-memory and the SQL backends.' + + (declaredSet.has(first) + ? ` The object's 'searchableFields' declares it, which is what made the entry ` + + 'look like coverage; remove it there as well.' + : '') + + ` Mirror the computed value onto a stored text field on '${object}' and search that instead.`; } else if (reason === 'unknown') { // A dotted path is a special unknown: plausible vocabulary from the // select/sort axes, but search scans this object's own columns. diff --git a/packages/objectql/src/query-expression-conformance.test.ts b/packages/objectql/src/query-expression-conformance.test.ts index c3313d2e20..2dd9530963 100644 --- a/packages/objectql/src/query-expression-conformance.test.ts +++ b/packages/objectql/src/query-expression-conformance.test.ts @@ -1272,3 +1272,188 @@ describe('#4254 — searchFields / groupBy / aggregations on the list path (real .rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD' }); }); }); + +/** + * [#6674] The known-but-VIRTUAL axis — the fail-open #4254 closed one axis over, + * surviving where the name is real. + * + * #4254 refuses a `$searchFields` entry the engine would not scan. A `formula` + * field slipped through the one gap that judgment had: the DECLARED branch + * admitted any entry that EXISTS, so declaring a formula field put it in the + * allowed set, and the gate — reading that same set — accepted it. Measured on + * `origin/main` before this change: + * + * ``` + * AUTO: {"allowed":["name","project_name"],"source":"auto"} formula excluded + * DECL-FORMULA: {"allowed":["name","project_name_formula"],"source":"declared"} admitted verbatim + * ?search=Apollo&searchFields=project_name_formula -> 200, 0 rows silent + * ``` + * + * Zero rows is the whole defect: a formula value is computed on read, so no + * driver materializes a column for `$contains` to scan — 0 rows on + * driver-memory (the property is absent from the stored row) and 0 rows WITH NO + * ERROR on driver-sql/better-sqlite3. The declaration reads as search coverage + * and delivers none, which is the "an unapplied filter must not look like a + * satisfied one" family (#3948) with the sign that matters here: the caller + * asked to search a column and got a well-formed empty answer. + * + * Refused now, with its own message, because BOTH neighbouring messages are + * wrong for it: "outside the declared set" is false (it may be IN the list), + * and the auto-default's "declare `searchableFields` to choose the searchable + * set" would instruct the author to write the very declaration being refused. + */ +describe('#6674 — a virtual formula field named in searchFields (real ObjectQL engine)', () => { + let engine: ObjectQL; + let protocol: ObjectStackProtocolImplementation; + let stores: Map>>; + + /** Declares a formula field searchable — the card's shape exactly. */ + const virtualObject = { + name: 'showcase_virtual', + label: 'Virtual', + searchableFields: ['name', 'project_name_formula'], + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + project_name_formula: { + name: 'project_name_formula', label: 'Project (formula)', + type: 'formula' as const, expression: "record.name + ' · Apollo'", + }, + }, + }; + + /** No declaration at all — the auto-default branch of the same question. */ + const autoObject = { + name: 'showcase_auto_virtual', + label: 'Auto Virtual', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + label_formula: { + name: 'label_formula', label: 'Label (formula)', + type: 'formula' as const, expression: 'record.name', + }, + }, + }; + + const ids = (r: any): string[] => r.records.map((x: any) => x.id); + + beforeEach(async () => { + engine = new ObjectQL(); + const made = makeStubDriver(); + stores = made.stores; + engine.registerDriver(made.driver, true); + await engine.init(); + engine.registry.registerObject(virtualObject as any, 'test-package'); + engine.registry.registerObject(autoObject as any, 'test-package'); + protocol = new ObjectStackProtocolImplementation(engine); + + // The stored row carries `name` only. That IS the fixture's point: the + // formula's computed value would contain "Apollo", and the column that + // would have to hold it does not exist. + stores.set('showcase_virtual', new Map([ + ['v1', { id: 'v1', name: 'Widget' }], + ['v2', { id: 'v2', name: 'Apollo Widget' }], + ])); + stores.set('showcase_auto_virtual', new Map([['a1', { id: 'a1', name: 'Widget' }]])); + }); + + it('CONTROL — the declared NON-virtual entry still narrows and still matches', async () => { + // Non-vacuity for every rejection below: the same object, the same + // declaration, one entry over, answers rows. A conformance block that + // only asserted refusals would pass just as happily with search broken. + expect(ids(await protocol.findData({ + object: 'showcase_virtual', query: { search: 'Apollo', searchFields: 'name' }, + }))).toEqual(['v2']); + // …and with no override at all, the search still runs over the surviving + // declared entry. Stock compatibility: an already-published object whose + // `searchableFields` names a formula field keeps answering plain + // searches, over the same rows as before, because the dropped entry + // matched nothing anyway. + expect(ids(await protocol.findData({ + object: 'showcase_virtual', query: { search: 'Apollo' }, + }))).toEqual(['v2']); + }); + + it('the DECLARED formula entry is refused — 400 INVALID_FIELD, not 200 with no rows', async () => { + await expect(protocol.findData({ + object: 'showcase_virtual', + query: { search: 'Apollo', searchFields: 'project_name_formula' }, + })).rejects.toMatchObject({ + status: 400, code: 'INVALID_FIELD', + field: 'project_name_formula', object: 'showcase_virtual', + }); + await expect(protocol.findData({ + object: 'showcase_virtual', + query: { search: 'Apollo', searchFields: 'project_name_formula' }, + })).rejects.toThrow(/is a virtual 'formula' field and cannot be searched/); + // The message must name WHY (no stored column) and the fix (a stored + // mirror) — the refusal is only useful if the author can act on it. + await expect(protocol.findData({ + object: 'showcase_virtual', + query: { search: 'Apollo', searchFields: 'project_name_formula' }, + })).rejects.toThrow(/computed on read and never stored/); + await expect(protocol.findData({ + object: 'showcase_virtual', + query: { search: 'Apollo', searchFields: 'project_name_formula' }, + })).rejects.toThrow(/Mirror the computed value onto a stored text field/); + }); + + it('the objectui echo — the whole declaration, formula entry included — is refused', async () => { + // The path this actually reaches production on: objectui's list search + // sends `$searchFields: schema.searchableFields` verbatim, so the object + // that declares a formula field 400s its own toolbar search. That is the + // blast radius the corpus count bounds, and it is why the message says + // the declaration is what to fix. + await expect(protocol.findData({ + object: 'showcase_virtual', + query: { search: 'Apollo', searchFields: ['name', 'project_name_formula'] }, + })).rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', field: 'project_name_formula' }); + await expect(protocol.findData({ + object: 'showcase_virtual', + query: { search: 'Apollo', searchFields: ['name', 'project_name_formula'] }, + })).rejects.toThrow(/searchableFields' declares it/); + }); + + it('an UNDECLARED formula field gets the same reason, not the auto-default advice', async () => { + // Before #6674 this fell to the auto-default branch, whose message ends + // "Declare 'searchableFields' on the object to choose the searchable set + // explicitly" — advice that, followed, produces exactly the declaration + // the case above refuses. The virtual reason is checked BEFORE the + // source split for that reason. + await expect(protocol.findData({ + object: 'showcase_auto_virtual', query: { search: 'x', searchFields: 'label_formula' }, + })).rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', field: 'label_formula' }); + await expect(protocol.findData({ + object: 'showcase_auto_virtual', query: { search: 'x', searchFields: 'label_formula' }, + })).rejects.toThrow(/is a virtual 'formula' field/); + await expect(protocol.findData({ + object: 'showcase_auto_virtual', query: { search: 'x', searchFields: 'label_formula' }, + })).rejects.not.toThrow(/choose the searchable set explicitly/); + }); + + it('CONTROL — the #4254 axes are untouched: unknown, stale and unsearchable keep their messages', async () => { + // The neighbour must not regress. Three distinct reasons, three + // distinct messages, all still reached. + await expect(protocol.findData({ + object: 'showcase_virtual', query: { search: 'x', searchFields: 'no_such_field' }, + })).rejects.toThrow(/Unknown field 'no_such_field'/); + + engine.registry.registerObject({ + name: 'showcase_mixed', + label: 'Mixed', + searchableFields: ['title', 'ghost'], + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' }, + estimate: { name: 'estimate', label: 'Estimate', type: 'number' }, + }, + } as any, 'test-package'); + await expect(protocol.findData({ + object: 'showcase_mixed', query: { search: 'x', searchFields: 'ghost' }, + })).rejects.toThrow(/declared in 'searchableFields' but does not exist/); + await expect(protocol.findData({ + object: 'showcase_mixed', query: { search: 'x', searchFields: 'estimate' }, + })).rejects.toThrow(/declares 'searchableFields'/); + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index cf4e2ffdd0..2c4934c68c 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1797,7 +1797,7 @@ const ObjectSchemaBase = strictObject( /** * Search Engine Config */ - searchableFields: z.array(z.string()).optional().describe('Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields.'), + searchableFields: z.array(z.string()).optional().describe('Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused (#6674) — mirror the value onto a stored text field and declare that.'), /** * System Capabilities diff --git a/packages/spec/src/data/search-fields.test.ts b/packages/spec/src/data/search-fields.test.ts index 98208bbf09..dfaad188c3 100644 --- a/packages/spec/src/data/search-fields.test.ts +++ b/packages/spec/src/data/search-fields.test.ts @@ -2,10 +2,12 @@ import { describe, it, expect } from 'vitest'; import { + isVirtualSearchField, resolveSearchFieldResolution, resolveSearchFields, SEARCH_AUTO_EXCLUDED_FIELDS, SEARCH_AUTO_EXCLUDED_TYPES, + SEARCH_VIRTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES, } from './search-fields'; @@ -187,3 +189,133 @@ describe('[#6934] search type vocabularies are pairwise disjoint', () => { } }); }); + +// --------------------------------------------------------------------------- +// [#6674] A VIRTUAL field declared in `searchableFields` is not admitted. +// +// The fail-open shape #4254 closed on the unknown-name axis, surviving one axis +// over: a `formula` entry names a REAL field, so the existence filter passed it, +// the declared branch returned it verbatim, and every search against it matched +// nothing — measured 0 rows on driver-memory and 0 rows WITH NO ERROR on +// driver-sql/better-sqlite3, because no driver materializes a column for a value +// that is computed on read. +// +// What is pinned here is the DECIDING face: what a declaration is admitted to +// say. The loud half lives at the two enforcement faces (the #4254 REST ingress +// gate and the linter), which read `isVirtualSearchField` to word their refusal +// from this same judgment. +// --------------------------------------------------------------------------- +describe('[#6674] a virtual field declared in searchableFields', () => { + const fields = { + id: { type: 'text' }, + name: { type: 'text' }, + project_name: { type: 'text' }, + project_id: { type: 'lookup' }, + payload: { type: 'json' }, + project_name_formula: { type: 'formula' }, + }; + + it('is dropped from the declared allowed set', () => { + // Before #6674 this returned + // { allowed: ['name', 'project_name_formula'], source: 'declared' } + // — the card's own transcript, admitted verbatim. + expect( + resolveSearchFieldResolution({ + fields, + searchableFields: ['name', 'project_name_formula'], + }), + ).toEqual({ allowed: ['name'], source: 'declared' }); + }); + + it('CONTROL — the declared branch still bypasses the auto-default exclusions', () => { + // The dividing line is STORAGE, not search quality. `lookup` and `json` are + // auto-EXCLUDED types, and declaring them is still the author's choice: the + // engine runs a `$contains` over the stored foreign key / the stored JSON + // text. Narrow and rarely useful, but a scan that CAN match — so it must not + // be swept up by the virtual filter. This control is what keeps #6674 from + // silently becoming "the declared branch is type-filtered after all", which + // would reject metadata the runtime accepts (ADR-0072 D1, and the #4830 + // changeset's explicit carve-out). + expect( + resolveSearchFieldResolution({ + fields, + searchableFields: ['project_id', 'payload'], + }), + ).toEqual({ allowed: ['project_id', 'payload'], source: 'declared' }); + }); + + it('CONTROL — a declaration naming no virtual field is untouched', () => { + expect( + resolveSearchFieldResolution({ fields, searchableFields: ['name', 'project_name'] }), + ).toEqual({ allowed: ['name', 'project_name'], source: 'declared' }); + }); + + it('an ALL-virtual declaration falls through to the auto-default', () => { + // The degenerate case, pinned rather than left to be discovered: the + // declared list filters to empty and resolution falls through exactly as it + // has for an all-STALE declaration since #4254. Direction is worth naming — + // search widens, from "matched nothing, ever" to the auto-default set. It is + // not left silent: the linter reports the declaration as a build error, and + // the ingress gate refuses any echoed `$searchFields` naming the entry. + expect( + resolveSearchFieldResolution({ fields, searchableFields: ['project_name_formula'] }), + ).toEqual({ allowed: ['name', 'project_name'], source: 'auto' }); + }); + + it('the override intersection can no longer reach a virtual field', () => { + expect( + resolveSearchFields({ + fields, + searchableFields: ['name', 'project_name_formula'], + requestedFields: 'project_name_formula', + }), + ).toEqual(['name']); // empty intersection → the tolerant fallback, never the formula + }); + + it('the auto-default never admitted one (the already-correct baseline)', () => { + expect(resolveSearchFieldResolution({ fields }).allowed).not.toContain('project_name_formula'); + }); +}); + +describe('[#6674] SEARCH_VIRTUAL_TYPES is a storage fact', () => { + it('is exactly the driver-virtual set', () => { + // Mirrors `fieldHasColumn` (driver-sql/src/schema-drift.ts) and + // driver-turso's "Virtual — no column" skips. A driver growing a second + // virtual type must widen this deliberately — a silent divergence would put + // the refusal and the storage rule back out of step, which is the drift + // #4254 moved this resolution into the spec to prevent. + expect([...SEARCH_VIRTUAL_TYPES].sort()).toEqual(['formula']); + }); + + it('is disjoint from all three search vocabularies', () => { + // Same contradiction #6934 pins for the other pairs: a type both virtual and + // searchable-textual would be admitted to the auto-default AND refused by + // name, with the outcome decided by evaluation order rather than by a rule. + const overlap = (a: ReadonlySet, b: ReadonlySet) => + [...a].filter((t) => b.has(t)).sort(); + expect(overlap(SEARCH_VIRTUAL_TYPES, SEARCHABLE_TEXTUAL_TYPES)).toEqual([]); + expect(overlap(SEARCH_VIRTUAL_TYPES, SEARCHABLE_ENUM_TYPES)).toEqual([]); + expect(overlap(SEARCH_VIRTUAL_TYPES, SEARCH_AUTO_EXCLUDED_TYPES)).toEqual([]); + }); + + it('an unreadable type is NOT virtual — unresolvable is not wrong', () => { + // The lint mirror feeds stub metadata (`{}`) for registry-injected system + // columns whose real type it cannot see. A stub must survive the filter, or + // `searchableFields: ['name', 'created_at']` would lose its system entry at + // resolution time and 400 at the gate (ADR-0072 D1). + expect(isVirtualSearchField({})).toBe(false); + expect(isVirtualSearchField(undefined)).toBe(false); + expect(isVirtualSearchField(null)).toBe(false); + expect(isVirtualSearchField({ type: 'text' })).toBe(false); + expect(isVirtualSearchField({ type: 'formula' })).toBe(true); + }); + + it('a declared system column survives the filter via its stub meta', () => { + expect( + resolveSearchFieldResolution({ + fields: { name: { type: 'text' }, created_at: {} }, + searchableFields: ['name', 'created_at'], + }), + ).toEqual({ allowed: ['name', 'created_at'], source: 'declared' }); + }); +}); diff --git a/packages/spec/src/data/search-fields.ts b/packages/spec/src/data/search-fields.ts index db733c3e4c..3cb122f973 100644 --- a/packages/spec/src/data/search-fields.ts +++ b/packages/spec/src/data/search-fields.ts @@ -19,7 +19,8 @@ * expand axis by having gate and engine read `REFERENCE_VALUE_TYPES`. * * Resolution precedence (server-side, never client-trusted): - * 1. the object's declared `searchableFields` (filtered to fields that exist) + * 1. the object's declared `searchableFields` (filtered to entries that exist + * AND have a stored column to scan — #6674) * 2. an auto-default: the display/name field + short-text and enum fields. * * An explicit `$searchFields` override is then INTERSECTED with that allowed @@ -48,6 +49,48 @@ export const SEARCH_AUTO_EXCLUDED_TYPES: ReadonlySet = new Set([ 'json', 'object', 'grid', 'image', 'file', 'avatar', 'vector', 'location', 'geometry', 'secret', 'password', 'encrypted', 'boolean', 'lookup', 'master_detail', ]); +/** + * [#6674] Field types with NO STORED COLUMN — the value is computed on read, so + * a `$contains` scan has nothing to look at. Exactly `formula` today. + * + * This is a STORAGE fact, not a taste judgment, and that is what separates it + * from the three vocabularies above. `SEARCH_AUTO_EXCLUDED_TYPES` says "the + * auto-default does not GUESS this type" — an author may still declare a `json` + * or `lookup` column in `searchableFields` and the engine executes it (a + * `$contains` over the stored JSON text, over the stored foreign key), which is + * a choice that can match. A `formula` entry cannot: the drivers materialize no + * column for it (`driver-sql/src/schema-drift.ts` `fieldHasColumn`, + * `driver-turso/src/remote-transport.ts` "Virtual — no column"), and the engine + * excludes it from the projection it sends down (`objectql/src/engine.ts` + * `buildFormulaPlan`) because the driver would fail on the column name. + * Measured on both backends: `{formula_field: {$contains: 'Apollo'}}` returns + * 0 rows on driver-memory (the property is absent from the stored row) and 0 + * rows with NO error on driver-sql/better-sqlite3. + * + * So the declared branch of {@link resolveSearchFieldResolution} filters these + * out, and the two enforcement faces refuse them by NAME — see that function. + * + * The set is pinned to exactly `['formula']` in `search-fields.test.ts`: it + * mirrors the drivers' own storage rule, and a driver growing a second virtual + * type must widen this deliberately rather than by drift. + */ +export const SEARCH_VIRTUAL_TYPES: ReadonlySet = new Set(['formula']); + +/** + * Is this field virtual — computed on read, with no stored column for `search` + * to scan? The ONE judgment behind the refusal, exported so the REST ingress + * gate (`assertSearchFieldsAreSearchable`) and the linter + * (`validate-searchable-fields`) word their refusals from the same fact the + * resolution below applies, instead of each carrying its own type literal — + * the one-source move #4254 made between gate and engine. + * + * A field whose `type` is unreadable is NOT virtual: unresolvable is not wrong + * (ADR-0072 D1), and the lint mirror feeds stub metadata (`{}`) for + * registry-injected system columns it cannot see. + */ +export function isVirtualSearchField(meta: SearchFieldMeta | undefined | null): boolean { + return !!meta && typeof meta.type === 'string' && SEARCH_VIRTUAL_TYPES.has(meta.type); +} export interface SearchFieldResolutionOptions { /** The object's field map (name → metadata). */ @@ -129,18 +172,57 @@ function autoDefaultFields(fields: Record, displayField /** * The ALLOWED search-field set for an object, plus where it came from — - * `declared` when the object's `searchableFields` (filtered to fields that - * exist) is non-empty, `auto` otherwise. The source matters to the #4254 - * ingress gate because the two rejections it explains differ: a field missing - * from a declared list is fixed by adding it there, while a field the - * auto-default skips is excluded by its TYPE and needs `searchableFields` + * `declared` when the object's `searchableFields` (filtered to entries the + * engine can actually scan) is non-empty, `auto` otherwise. The source matters + * to the #4254 ingress gate because the two rejections it explains differ: a + * field missing from a declared list is fixed by adding it there, while a field + * the auto-default skips is excluded by its TYPE and needs `searchableFields` * declared to become a target. + * + * TWO filters run on the declared branch, and they are different claims: + * + * 1. EXISTENCE — an entry naming no field is dropped. A stale declaration + * (#4254); the ingress gate calls it out by that name. + * 2. [#6674] SCANNABILITY — an entry naming a VIRTUAL field + * ({@link isVirtualSearchField}) is dropped. The entry names a real field, + * passes existence, and can still never match, because the field has no + * stored column for `$contains` to scan. + * + * Filter 2 is deliberately NOT a type allow-list on the declared branch. The + * declaration remains the author's explicit choice and still bypasses the + * auto-default's exclusions: a `json` or `lookup` column declared here is + * executed by the engine as a `$contains` over the stored JSON text / stored + * foreign key — narrow, rarely useful, but a scan that CAN match. Only "there + * is no column at all" is refused, which is why the vocabulary is a storage + * fact rather than a search-quality one. + * + * Why the drop is not the whole fix. Dropping alone would only make the + * declaration silently NARROWER — the failure mode #4254 exists to close on + * the neighbouring axis. So the two enforcement faces refuse a virtual entry + * loudly, by name, from this same judgment: + * + * - the INGRESS gate (`assertSearchFieldsAreSearchable`, + * `@objectstack/metadata-protocol`) → `400 INVALID_FIELD`, because clients + * echo the declaration verbatim as `$searchFields`; + * - the LINTER (`validate-searchable-fields`, `@objectstack/lint`) → a build + * error at authoring time, on the object's own set as well as a view's. + * + * This function itself stays non-throwing on purpose: it is consulted on every + * search by internal callers (hooks, flows, registry-less hosts) that never + * pass an ingress, and #4254 put the loudness at the ingress for exactly that + * reason. What changes here is what a declaration is ADMITTED to say. + * + * Degenerate case, pinned in the tests: a declaration whose entries are ALL + * virtual filters to empty and therefore falls through to the auto-default — + * the same behaviour an all-stale declaration has had since #4254. Search + * widens from "matched nothing, ever" to the auto-default set, and the linter + * reports the declaration as an error rather than leaving the swap silent. */ export function resolveSearchFieldResolution( opts: Omit, ): { allowed: string[]; source: 'declared' | 'auto' } { const all = opts.fields || {}; - const declared = opts.searchableFields?.filter((f) => all[f]); + const declared = opts.searchableFields?.filter((f) => all[f] && !isVirtualSearchField(all[f])); if (declared && declared.length > 0) return { allowed: declared, source: 'declared' }; return { allowed: autoDefaultFields(all, opts.displayField), source: 'auto' }; } diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 55b43d849e..64facfe412 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -142,9 +142,11 @@ also lands in the auto-default set when the object declares no driver materializes a column for it, so a `$contains` predicate against one has nothing to scan (the SQL driver would emit a `WHERE` over a column that does not exist). CEL also only reads this record's own fields (`record.`), so a -formula cannot fetch the related title in the first place. Nothing rejects the -mistake: `searchableFields` admits any field the object declares, so a formula -entry clears both lint and the ingress gate and then never matches. +formula cannot fetch the related title in the first place. Since #6674 the +mistake is **refused, not silent**: a `formula` entry in any `searchableFields` +— the object's own set included — is an `os validate` error +(`searchable-field-unsearchable`), and a request naming one is `400 +INVALID_FIELD`. It used to clear both and then never match. **Mirror maintenance is the trade-off** — a mirror is denormalized data, only as fresh as whatever writes it. Cover both write paths: diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index f3d430aa2b..9d36d28de9 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -403,6 +403,7 @@ result, no result at all. | a renamed / mistyped column | `searchable-field-unknown` | `400 INVALID_FIELD` | | a dotted path (`account_id.name`) | `searchable-field-unknown` | `400 INVALID_FIELD` | | a real column outside the allowed set | `searchable-field-unsearchable` | `400 INVALID_FIELD` | +| a virtual `formula` column — nothing stored to scan (#6674) | `searchable-field-unsearchable` | `400 INVALID_FIELD` | Both diagnostics are **errors**, not warnings — `os validate` fails the build. The two you will actually hit, verbatim: From ce9e454ac235776222ced7256d731225c1b66e54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 16:18:21 +0000 Subject: [PATCH 2/3] chore(spec): regenerate the api-surface snapshot for the two #6674 exports `check:api-surface` (inside the TypeScript Type Check job) judged the public surface "0 breaking, 2 added" and asked for the snapshot. Both additions are intentional and are the design's centre: `SEARCH_VIRTUAL_TYPES` and `isVirtualSearchField` are the ONE judgment the spec resolution, the #4254 ingress gate and the linter all read, so that they cannot drift about which field types have a stored column. Snapshot delta is exactly those two names in api-surface/data.json. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 --- packages/spec/api-surface/data.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 61609f5ae1..43069af046 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -476,6 +476,7 @@ "SEARCHABLE_TEXTUAL_TYPES (const)", "SEARCH_AUTO_EXCLUDED_FIELDS (const)", "SEARCH_AUTO_EXCLUDED_TYPES (const)", + "SEARCH_VIRTUAL_TYPES (const)", "SINGLE_OPTION_TYPES (const)", "SQLDialect (type)", "SQLDialectSchema (const)", @@ -638,6 +639,7 @@ "isTenancyDisabled (function)", "isTitleEligible (function)", "isUniqueDeclared (function)", + "isVirtualSearchField (function)", "lintAuthoredRecordKeys (function)", "missingFieldValues (function)", "nextUtcCalendarDay (function)", From b3d8e80b461df58ee923e94965b86cd7f70d3aae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 18:10:57 +0000 Subject: [PATCH 3/3] chore(spec): record the two #6674 exports in the #7090 export-origins baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7090 landed `check:export-origins` after this branch was cut, so the merge of `origin/main` brought a required gate the branch had never satisfied: the `data` shard was stale for the two exports this PR adds. `pnpm --filter @objectstack/spec gen:export-origins` — one shard rewritten, two added lines, both resolving to the single declaration site `src/data/search-fields.ts`. No re-homed origin and no second origin for an existing name, so this is not the #4411 dual-source trap the gate warns about; `check:dual-source-exports` agrees (0 new). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 --- packages/spec/export-origins/data.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 2227529703..82a3c47249 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -476,6 +476,7 @@ "SEARCHABLE_TEXTUAL_TYPES": "src/data/search-fields.ts#SEARCHABLE_TEXTUAL_TYPES (const)", "SEARCH_AUTO_EXCLUDED_FIELDS": "src/data/search-fields.ts#SEARCH_AUTO_EXCLUDED_FIELDS (const)", "SEARCH_AUTO_EXCLUDED_TYPES": "src/data/search-fields.ts#SEARCH_AUTO_EXCLUDED_TYPES (const)", + "SEARCH_VIRTUAL_TYPES": "src/data/search-fields.ts#SEARCH_VIRTUAL_TYPES (const)", "SINGLE_OPTION_TYPES": "src/data/field-value.zod.ts#SINGLE_OPTION_TYPES (const)", "SQLDialect": "src/data/driver-sql.zod.ts#SQLDialect (type)", "SQLDialectSchema": "src/data/driver-sql.zod.ts#SQLDialectSchema (const)", @@ -638,6 +639,7 @@ "isTenancyDisabled": "src/data/object.zod.ts#isTenancyDisabled (function)", "isTitleEligible": "src/data/display-name.ts#isTitleEligible (function)", "isUniqueDeclared": "src/data/field.zod.ts#isUniqueDeclared (function)", + "isVirtualSearchField": "src/data/search-fields.ts#isVirtualSearchField (function)", "lintAuthoredRecordKeys": "src/data/authoring-key-lint.ts#lintAuthoredRecordKeys (function)", "missingFieldValues": "src/data/autonumber-format.ts#missingFieldValues (function)", "nextUtcCalendarDay": "src/data/calendar-day.ts#nextUtcCalendarDay (function)",