|
| 1 | +--- |
| 2 | +"@objectstack/metadata-protocol": patch |
| 3 | +"@objectstack/spec": patch |
| 4 | +"@objectstack/lint": patch |
| 5 | +--- |
| 6 | + |
| 7 | +fix(spec,lint): a virtual `formula` field in `searchableFields` is refused loudly, not admitted verbatim (#6674) |
| 8 | + |
| 9 | +#4254 closed the fail-open on the unknown-name axis: a `$searchFields` entry the |
| 10 | +engine would not scan is `400 INVALID_FIELD`, never a silently widened search. |
| 11 | +The same shape survived one axis over, on names that are perfectly real. |
| 12 | + |
| 13 | +The declared branch of `resolveSearchFieldResolution` filtered entries by |
| 14 | +EXISTENCE only, so a `formula` field declared in `searchableFields` entered the |
| 15 | +allowed set — and the ingress gate, which reads that same set, accepted it for |
| 16 | +exactly that reason. Measured on `origin/main`: |
| 17 | + |
| 18 | +``` |
| 19 | +AUTO: {"allowed":["name","project_name"],"source":"auto"} formula excluded |
| 20 | +DECL-FORMULA: {"allowed":["name","project_name_formula"],"source":"declared"} admitted verbatim |
| 21 | +?search=Apollo&searchFields=project_name_formula -> 200, 0 rows silent |
| 22 | +``` |
| 23 | + |
| 24 | +Zero rows is the defect. A formula value is computed on read and no driver |
| 25 | +materializes a column for it (`driver-sql` `fieldHasColumn`, driver-turso's |
| 26 | +"Virtual — no column"), so the `$contains` the engine expands `$search` into has |
| 27 | +nothing to scan: 0 rows on driver-memory (the property is absent from the stored |
| 28 | +row) and 0 rows WITH NO ERROR on driver-sql/better-sqlite3. The declaration read |
| 29 | +as search coverage and delivered none. |
| 30 | + |
| 31 | +- **`@objectstack/spec` — the deciding face.** The declared branch now filters on |
| 32 | + existence AND scannability: an entry naming a virtual field is not admitted. |
| 33 | + New exports `SEARCH_VIRTUAL_TYPES` (exactly `formula`, pinned) and |
| 34 | + `isVirtualSearchField` — one judgment, so the resolution, the gate and the |
| 35 | + linter cannot drift about which types have a column. The resolution itself |
| 36 | + stays non-throwing: it is consulted on every search by internal callers that |
| 37 | + never pass an ingress, which is why #4254 put the loudness at the ingress. |
| 38 | +- **`@objectstack/metadata-protocol` — `400 INVALID_FIELD` with its own reason.** |
| 39 | + Split out before the declared/auto branch, because both of those messages are |
| 40 | + wrong for it: "outside the declared set" is false when the entry IS in the |
| 41 | + list, and the auto-default's "declare `searchableFields` to choose the |
| 42 | + searchable set" would instruct the author to write the declaration being |
| 43 | + refused. The new message names the field, its type, that the value is computed |
| 44 | + on read and never stored, and the fix (mirror onto a stored text field). |
| 45 | +- **`@objectstack/lint` — a build error at authoring time**, on the object's own |
| 46 | + `searchableFields` as well as a view's narrowing, under the existing |
| 47 | + `searchable-field-unsearchable` rule (no new rule id). This narrows the |
| 48 | + canonical surface, which #4830 had deliberately left existence-only. |
| 49 | + |
| 50 | +The carve-out that made canonical existence-only is deliberately KEPT and pinned |
| 51 | +by controls in all three packages: the dividing line is STORAGE, not search |
| 52 | +quality. A `json` or `lookup` column declared in `searchableFields` is still the |
| 53 | +author's choice and still executed — a `$contains` over the stored JSON text or |
| 54 | +the stored foreign key. Narrow and rarely useful, but a scan that CAN match, so |
| 55 | +it is neither a 400 nor a finding. Only "there is no column at all" is refused. |
| 56 | + |
| 57 | +**Compatibility.** A corpus sweep of this repo plus `objectui` and `cloud` found |
| 58 | +ZERO authored `searchableFields` naming a formula-typed field, so nothing in the |
| 59 | +tree changes verdict. For an already-published object that does carry one: |
| 60 | +loading is unaffected (no schema-parse change — `searchableFields` is still |
| 61 | +`z.array(z.string())`, this is a resolution and enforcement rule); a plain |
| 62 | +`?search=` keeps returning the SAME rows, because the dropped entry matched none |
| 63 | +of them; only a request that NAMES the formula field flips from `200` with no |
| 64 | +rows to `400 INVALID_FIELD` — including objectui's list search, which echoes the |
| 65 | +declaration verbatim. An object whose `searchableFields` is ENTIRELY formula |
| 66 | +entries filters to empty and falls through to the auto-default, exactly as an |
| 67 | +all-stale declaration has since #4254; the linter reports the declaration rather |
| 68 | +than leaving that swap silent. |
0 commit comments