Skip to content

Commit b3de0dd

Browse files
os-helpclaude
andauthored
fix(objectql,runtime): stop platform stamps failing spec validation (#7561) (#7591)
`GET /api/v1/meta/diagnostics` reported 94 of 94 registry entries INVALID. Both error shapes were self-inflicted — the platform reporting defects about columns it wrote itself, on documents no author wrote or could fix — so the endpoint carried no signal at all. - `provisionSearchCompanion` stamped `index: true` on `__search`. Field-level `index` was removed from `FieldSchema` in the 16.x line (#2377, ADR-0049) and `FieldSchema` is a `strictObject`, so the key was rejected by name. Same mechanism as #6810 (`applySystemFields`/`indexed` on `organization_id`), same retired key, one field over. Stamp and the false "It IS `index`ed" docblock claim both removed. Unlike #6810 no `indexes[]` entry replaces it: the column's only reader is `{ __search: { $contains: term } }`, a leading-wildcard LIKE no B-tree can serve, and `IndexSchema` cannot express a trigram/GIN index. Behaviour-neutral either way — nothing read the flag. - `DefaultDatasourcePlugin.registerVisibility` published the `default` row without `config`, which `DatasourceSchema` requires. Now stamped `{}` — deliberately empty rather than the host's real config, which carries connection credentials that would otherwise reach every metadata reader. Fixed at the producer, not by widening the spec. Two pins land with the fix, since patching one key at a time is what turned #6810 into this card: a class pin walking every platform-stamped field through `FieldSchema` across all stamper branches, and a baseline pin asserting a realistically-built registry sweeps clean, naming both error shapes. Both reverse-verified red against the unfixed producers. `search-companion.test.ts` asserted `col.index === true` — it pinned the defect rather than the contract; it now pins the key's absence. Fixes #7561 Claude-Session: https://claude.ai/code/session_019MfMR4enbnPgoH6efeKmDJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 245d1dc commit b3de0dd

6 files changed

Lines changed: 407 additions & 4 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(objectql,runtime): stop the platform's own stamps from failing spec validation — `/meta/diagnostics` reads clean again (#7561)
7+
8+
`GET /api/v1/meta/diagnostics` reported **94 of 94** registry entries INVALID —
9+
every entry, `sys_*` and `showcase_*` alike. The endpoint reports entries that
10+
fail their registered Zod schema, so at a 94/94 baseline it carried **no
11+
signal**: a genuinely broken object was indistinguishable from a healthy one,
12+
and any gate or dashboard built on it read permanently red.
13+
14+
Both error shapes behind the 94 were self-inflicted — the platform reporting
15+
defects about columns it wrote itself, on documents no author wrote or could
16+
fix.
17+
18+
**`fields.__search: Unrecognized key 'index'`.** `provisionSearchCompanion`
19+
stamped `index: true` on the hidden `__search` companion column. Field-level
20+
`index` was removed from `FieldSchema` in the 16.x line (#2377, ADR-0049)
21+
because a field-level index flag built no index, and `FieldSchema` is a
22+
`strictObject`, so the key was rejected by name. The companion is provisioned
23+
before the document is stored and `/meta` re-parses the served body, so the
24+
stamp badged `_diagnostics: { valid: false }` onto every object the platform
25+
provisions a companion for. This is the #6810 mechanism one field over
26+
(`applySystemFields` stamping `indexed` on `organization_id`), and the same
27+
retired key. The stamp is gone, along with the docblock claim that the column
28+
"IS `index`ed".
29+
30+
Unlike #6810 the index is **not** re-declared in the object's `indexes[]`, and
31+
that difference is measured rather than overlooked. #6810's predicate is
32+
`organization_id = ?` — equality, which a B-tree serves. This column's only
33+
reader is `buildSearchFilter`, which emits `{ __search: { $contains: term } }`
34+
— a leading-wildcard `LIKE '%term%'` no B-tree can answer — and `IndexSchema`
35+
spells nothing else (`name` / `fields` / `unique`; no trigram/GIN method).
36+
Declaring one would buy write amplification on every row for a read path that
37+
cannot use it. Search behaviour is unchanged either way: nothing read the flag.
38+
39+
**`config: expected record, received undefined`.** The datasource-visibility
40+
registration in `DefaultDatasourcePlugin` published the `default` row without
41+
`config`, which `DatasourceSchema` requires. It is now stamped `{}`
42+
deliberately empty, not the host's real config, which carries connection
43+
credentials that would otherwise land on `GET /api/v1/meta/datasources` for
44+
every metadata reader. No information is lost versus the omitted key; only the
45+
spelling changes to the one the contract accepts. Fixed at the producer rather
46+
than by widening the spec: a real datasource document genuinely needs its
47+
config, so relaxing the schema would trade one honest verdict for a permanently
48+
weaker one.
49+
50+
**So it stops recurring.** Two pins land with the fix, because patching one key
51+
at a time is what turned #6810 into this card. A class pin walks every field
52+
the platform stamps — `applySystemFields` and `provisionSearchCompanion`,
53+
across every ownership / tenancy / `systemFields` branch — through
54+
`FieldSchema`, so the next retired-key stamp turns a suite red instead of
55+
poisoning diagnostics. A baseline pin asserts a realistically-built registry
56+
sweeps clean, naming both of this card's error shapes explicitly; the 94/94
57+
state survived undetected until a human read the endpoint by hand, because
58+
nothing asserted the baseline.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#7561] THE BASELINE PIN — a healthy registry validates clean.
5+
*
6+
* ## Why this file exists
7+
*
8+
* `GET /api/v1/meta/diagnostics` reports every metadata entry that fails its
9+
* registered Zod schema. That verdict is only worth reading if the baseline is
10+
* ZERO: at 94 of 94 entries INVALID — where the platform's own
11+
* `applySystemFields`/`provisionSearchCompanion` stamps supplied both error
12+
* shapes — a genuinely broken object is indistinguishable from the baseline,
13+
* and any gate or dashboard built on the endpoint reads permanently red.
14+
*
15+
* The 94/94 state was not caught by any suite. It survived until a human read
16+
* the endpoint by hand during a QA run (#7514), which is the actual failure
17+
* this file addresses: nothing asserted the baseline. So this pin walks a
18+
* registry built the way the platform builds one — every stamper live, the
19+
* tenancy branch that #6810 broke armed — and asserts the sweep finds NOTHING.
20+
*
21+
* Companion to `stamped-system-fields-spec-conformance.test.ts`: that one pins
22+
* the PRODUCERS (whatever a stamper writes, `FieldSchema` accepts); this one
23+
* pins the OBSERVABLE the card was filed against (the served documents sweep
24+
* clean). A regression that slipped past the first — a bad key on a document
25+
* assembled somewhere other than a stamper — still fails here.
26+
*/
27+
28+
import { computeMetadataDiagnostics } from '@objectstack/metadata-protocol';
29+
import { describe, it, expect } from 'vitest';
30+
31+
import { SchemaRegistry } from './registry.js';
32+
33+
const PKG = 'showcase';
34+
35+
/**
36+
* A baseline shaped like the registry the card was filed against: `sys_*` and
37+
* `showcase_*` alike, spanning the branches that decide which system columns
38+
* get stamped, and every one carrying a title-eligible field so the `__search`
39+
* companion is actually provisioned (an object with no companion could not
40+
* reproduce #7561 and would pass vacuously).
41+
*/
42+
const OBJECTS: Array<Record<string, unknown>> = [
43+
{ name: 'showcase_account', label: 'Account', fields: { name: { type: 'text' }, revenue: { type: 'number' } } },
44+
{
45+
name: 'showcase_contact',
46+
label: 'Contact',
47+
ownership: 'user',
48+
fields: { name: { type: 'text' }, email: { type: 'email' } },
49+
},
50+
{
51+
name: 'showcase_order',
52+
label: 'Order',
53+
ownership: 'org',
54+
fields: { name: { type: 'text' }, total: { type: 'currency' } },
55+
},
56+
{ name: 'sys_thing', label: 'Thing', fields: { name: { type: 'text' } } },
57+
{
58+
name: 'showcase_note',
59+
label: 'Note',
60+
managedBy: 'platform',
61+
fields: { name: { type: 'text' }, body: { type: 'textarea' } },
62+
},
63+
];
64+
65+
/**
66+
* The `datasource` row `DefaultDatasourcePlugin.registerVisibility` publishes.
67+
* Reproduced as a literal rather than imported because the pin is about the
68+
* SHAPE that reaches the metadata list — importing `@objectstack/runtime` here
69+
* would invert the package dependency (runtime depends on objectql).
70+
* `default-datasource-plugin.ts` carries the matching `[#7561]` note.
71+
*/
72+
const DEFAULT_DATASOURCE_ROW = {
73+
name: 'default',
74+
label: 'Default',
75+
driver: 'sqlite',
76+
config: {},
77+
origin: 'code',
78+
};
79+
80+
function buildBaseline(multiTenant: boolean): SchemaRegistry {
81+
// `searchCompanion: true` explicitly: the flag defaults off the environment
82+
// (`OS_SEARCH_PINYIN_ENABLED`), and a pin that silently skipped the companion
83+
// would be green on exactly the deployments #7561 was reported from.
84+
const registry = new SchemaRegistry({ multiTenant, searchCompanion: true });
85+
for (const def of OBJECTS) registry.registerObject(structuredClone(def) as any, PKG);
86+
return registry;
87+
}
88+
89+
describe('[#7561] the baseline registry sweeps clean through /meta/diagnostics', () => {
90+
describe.each([true, false])('multiTenant: %s', (multiTenant) => {
91+
it('every served object document is spec-valid', () => {
92+
const registry = buildBaseline(multiTenant);
93+
const served = registry.getAllObjects(PKG);
94+
expect(served.length, 'no objects registered — pin would pass vacuously').toBe(OBJECTS.length);
95+
96+
// Report the SUBSTANCE — which entry, which path, which code — so a
97+
// regression names itself instead of asserting a bare boolean.
98+
const invalid: string[] = [];
99+
for (const doc of served) {
100+
const diag = computeMetadataDiagnostics('object', doc);
101+
expect(diag, `object/${(doc as any).name}: no schema registered`).toBeDefined();
102+
for (const err of diag!.errors ?? []) {
103+
invalid.push(`object/${(doc as any).name}${err.path}: ${err.code}`);
104+
}
105+
}
106+
expect(invalid).toEqual([]);
107+
});
108+
109+
it('the platform stamps the companion, and it is one of the entries swept', () => {
110+
// Guards the guard: the sweep above is only meaningful while `__search`
111+
// is actually present on the documents it validates.
112+
const registry = buildBaseline(multiTenant);
113+
const withCompanion = registry
114+
.getAllObjects(PKG)
115+
.filter((o: any) => o.fields?.__search !== undefined);
116+
expect(withCompanion.length).toBe(OBJECTS.length);
117+
});
118+
});
119+
120+
it('the default datasource row is spec-valid as registered', () => {
121+
// The second of the card's two error shapes: `config: expected record,
122+
// received undefined`, produced by the datasource-visibility registration
123+
// omitting a key `DatasourceSchema` requires.
124+
const diag = computeMetadataDiagnostics('datasource', DEFAULT_DATASOURCE_ROW);
125+
expect(diag, 'datasource has no registered schema').toBeDefined();
126+
expect(
127+
(diag!.errors ?? []).map((e) => `${e.path}: ${e.code}`),
128+
).toEqual([]);
129+
});
130+
131+
it("neither of the card's two error shapes appears anywhere in the sweep", () => {
132+
// Named explicitly so a REINTRODUCTION of either fails on the shape the
133+
// card reported, not on a generic count. These are the two strings a human
134+
// read off the endpoint during #7514.
135+
const all: Array<{ entry: string; path: string; message: string }> = [];
136+
for (const multiTenant of [true, false]) {
137+
for (const doc of buildBaseline(multiTenant).getAllObjects(PKG)) {
138+
for (const err of computeMetadataDiagnostics('object', doc)?.errors ?? []) {
139+
all.push({ entry: `object/${(doc as any).name}`, path: err.path ?? '', message: err.message ?? '' });
140+
}
141+
}
142+
}
143+
for (const err of computeMetadataDiagnostics('datasource', DEFAULT_DATASOURCE_ROW)?.errors ?? []) {
144+
all.push({ entry: 'datasource/default', path: err.path ?? '', message: err.message ?? '' });
145+
}
146+
147+
expect(all.filter((e) => e.path === 'fields.__search')).toEqual([]);
148+
expect(all.filter((e) => /Unrecognized key/i.test(e.message) && /`index`/.test(e.message))).toEqual([]);
149+
expect(all.filter((e) => e.path === 'config' && /expected record/i.test(e.message))).toEqual([]);
150+
});
151+
});

packages/objectql/src/search-companion.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,18 @@ describe('provisionSearchCompanion', () => {
8585
expect(col.readonly).toBe(true);
8686
expect(col.system).toBe(true);
8787
expect(col.searchable).toBe(false);
88-
expect(col.index).toBe(true);
88+
// [#7561] Was `expect(col.index).toBe(true)` — this line pinned the DEFECT
89+
// rather than the contract. `index` is not a `FieldSchema` key (removed in
90+
// the 16.x line, #2377 / ADR-0049, because a field-level index flag built no
91+
// index), and `FieldSchema` is a `strictObject`, so stamping it badged every
92+
// object carrying a companion `_diagnostics: { valid: false }` and drove
93+
// `GET /api/v1/meta/diagnostics` to 94/94 INVALID. The key is now absent,
94+
// and no index is declared in its place: this column's only reader is a
95+
// `$contains`, which no B-tree serves. See the docblock in
96+
// `search-companion.ts` and the pins in
97+
// `stamped-system-fields-spec-conformance.test.ts`.
98+
expect(col.index).toBeUndefined();
99+
expect(Object.keys(col)).not.toContain('index');
89100
});
90101

91102
it('is idempotent and skips ineligible / opted-out objects unchanged', () => {

packages/objectql/src/search-companion.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,31 @@ export function resolveSearchCompanionSources(schema: CompanionObjectMeta | unde
128128
* it never appears in auto-generated views/forms, is excluded from the
129129
* `$search` auto-default (hidden fields are skipped) and from `$searchFields`
130130
* overrides (the override intersects with the allowed set), and non-system
131-
* callers cannot forge it on update (#2948 readonly write guard). It IS
132-
* `index`ed — every search touches it.
131+
* callers cannot forge it on update (#2948 readonly write guard).
132+
*
133+
* [#7561] It carries NO index — and the stamp that claimed otherwise is gone.
134+
* This block used to append `index: true` and the paragraph above used to read
135+
* "It IS `index`ed — every search touches it". Both were false, in the #6810
136+
* shape one field over (`applySystemFields` stamping `indexed` on
137+
* `organization_id`): `index` was removed from `FieldSchema` in the 16.x line
138+
* (#2377, ADR-0049) because a field-level index flag built no index, and
139+
* `FieldSchema` is a `strictObject`, so the key was rejected BY NAME. The
140+
* companion is provisioned BEFORE the document is stored and `/meta` re-parses
141+
* the served body, so the stamp put `_diagnostics: { valid: false, errors:
142+
* [{ path: 'fields.__search', code: 'unrecognized_keys' }] }` on every object
143+
* the platform provisions a companion for — a defect the platform reported
144+
* about its own column, on a document no author wrote or could fix.
145+
*
146+
* Unlike #6810 the index is NOT re-declared in the object's `indexes[]`, and
147+
* that is a measured difference rather than an omission. #6810's predicate is
148+
* `organization_id = ?` — equality, which a B-tree serves. This column's ONLY
149+
* reader is `buildSearchFilter`, which emits `{ __search: { $contains: term } }`
150+
* (`search-filter.ts`) — a leading-wildcard `LIKE '%term%'` that no B-tree can
151+
* answer, and `IndexSchema` spells nothing else (`name` / `fields` / `unique`;
152+
* no trigram/GIN method). Declaring one would buy write amplification on every
153+
* row for a read path that cannot use it. If the companion ever warrants a
154+
* real substring index, it needs an `IndexSchema` that can express one — a
155+
* separate change, not a dead index declared here.
133156
*
134157
* Objects that opt out of search entirely (`searchable: false`, ADR-0061 D2)
135158
* are skipped: a companion no query will ever read is dead weight.
@@ -152,7 +175,6 @@ export function provisionSearchCompanion<T extends CompanionObjectMeta>(schema:
152175
readonly: true,
153176
system: true,
154177
searchable: false,
155-
index: true,
156178
description:
157179
`Search-normalized forms of the display/name field (normalizers: ${SEARCH_COMPANION_NORMALIZERS.join(', ')}) — ` +
158180
'e.g. full pinyin + initials for CJK names. Maintained by plugin-pinyin-search; never hand-edited. See #2486.',

0 commit comments

Comments
 (0)