diff --git a/.changeset/search-excluded-ghost-types.md b/.changeset/search-excluded-ghost-types.md new file mode 100644 index 0000000000..9314cbbac6 --- /dev/null +++ b/.changeset/search-excluded-ghost-types.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": patch +--- + +Drop four dead members — 'object', 'grid', 'geometry', 'encrypted' — from `SEARCH_AUTO_EXCLUDED_TYPES` (`search-fields.ts`). None of the four was ever a member of the `FieldType` enum at any commit, so none could ever match a real field's `type`: the exclusion set's live behaviour is unchanged, and the file's fail-closed tiebreak comment no longer asserts a safety property for names that cannot occur. The `[#13695]` pin in `search-fields.test.ts` is extended to hold every search type vocabulary (`SEARCHABLE_TEXTUAL_TYPES`, `SEARCHABLE_ENUM_TYPES`, `SEARCH_AUTO_EXCLUDED_TYPES`, `SEARCH_VIRTUAL_TYPES`) to `FieldType` membership, so a future ghost entry is a red test instead of a silent no-op. diff --git a/packages/spec/src/data/search-fields.test.ts b/packages/spec/src/data/search-fields.test.ts index 33a54f22d1..2c6295451c 100644 --- a/packages/spec/src/data/search-fields.test.ts +++ b/packages/spec/src/data/search-fields.test.ts @@ -144,25 +144,50 @@ describe('[#4483] $search auto field set — lead orders, never admits', () => { // and in opposite directions, so no single relaxation can make this pin vacuous. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- -// [#13695] `SEARCHABLE_ENUM_TYPES` members must be real `FieldType`s. +// [#13695] Every search type vocabulary in this file is a subset of `FieldType`. // -// The #6934 disjointness pins above check the vocabularies against EACH OTHER +// The #6934 disjointness pins below check the vocabularies against EACH OTHER // but never against `FieldType` itself, so a member that matches no real field // type at all — a pure ghost, distinct from an overlap — passed every existing -// pin silently: `'status'` sat in this set matching nothing, for as long as it -// took a docs sweep to notice by hand. This pin closes that probe gap for the -// enum vocabulary specifically (the one the finding hit); it is deliberately -// NOT extended to `SEARCH_AUTO_EXCLUDED_TYPES`, which is a separate, larger -// finding of its own (`object`/`grid`/`geometry`/`encrypted` are ghosts there -// too) filed out of scope for this fix. +// pin silently: `'status'` sat in `SEARCHABLE_ENUM_TYPES` matching nothing for +// as long as it took a docs sweep to notice by hand, and the same probe run +// against the other sets found four more in `SEARCH_AUTO_EXCLUDED_TYPES` +// (#13716: 'object', 'grid', 'geometry', 'encrypted' — never `FieldType` +// members at any commit, so the fail-closed tiebreak comment was asserting a +// safety property for names that cannot occur). This pin now holds ALL of the +// file's type vocabularies to the enum: an exclusion or allowance spelled with +// a name `FieldType` does not contain governs nothing. +// (`SEARCH_AUTO_EXCLUDED_FIELDS` is deliberately absent — its members are +// field NAMES, not types.) // --------------------------------------------------------------------------- -describe('[#13695] SEARCHABLE_ENUM_TYPES ⊆ FieldType', () => { +describe('[#13695] search type vocabularies ⊆ FieldType', () => { const validTypes: ReadonlySet = new Set(FieldType.options); + const vocabularies: ReadonlyArray<[string, ReadonlySet]> = [ + ['SEARCHABLE_TEXTUAL_TYPES', SEARCHABLE_TEXTUAL_TYPES], + ['SEARCHABLE_ENUM_TYPES', SEARCHABLE_ENUM_TYPES], + ['SEARCH_AUTO_EXCLUDED_TYPES', SEARCH_AUTO_EXCLUDED_TYPES], + ['SEARCH_VIRTUAL_TYPES', SEARCH_VIRTUAL_TYPES], + ]; - it('every member is a real FieldType — no ghost vocabulary entries', () => { - for (const t of SEARCHABLE_ENUM_TYPES) { - expect(validTypes.has(t), `'${t}' is in SEARCHABLE_ENUM_TYPES but not a FieldType member`).toBe(true); + it.each(vocabularies)('%s has no ghost members', (name, set) => { + const ghosts = [...set].filter((t) => !validTypes.has(t)).sort(); + expect(ghosts, `${name} names types that are not FieldType members`).toEqual([]); + }); + + it('CONTROL — the subset assertions are not vacuous', () => { + // A broken `FieldType` import (empty `options`) or an accidentally emptied + // vocabulary would green every subset check above while pinning nothing. + expect(validTypes.size).toBeGreaterThan(0); + for (const [name, set] of vocabularies) { + expect(set.size, `${name} is unexpectedly empty`).toBeGreaterThan(0); } + // Positive control — one known-real member per vocabulary, so the pin is + // measuring membership, not an accident of empty intersections. + expect(validTypes.has('text')).toBe(true); + expect(SEARCHABLE_TEXTUAL_TYPES.has('text')).toBe(true); + expect(SEARCHABLE_ENUM_TYPES.has('select')).toBe(true); + expect(SEARCH_AUTO_EXCLUDED_TYPES.has('secret')).toBe(true); + expect(SEARCH_VIRTUAL_TYPES.has('formula')).toBe(true); }); }); diff --git a/packages/spec/src/data/search-fields.ts b/packages/spec/src/data/search-fields.ts index d80e9f8c0b..5616ff7470 100644 --- a/packages/spec/src/data/search-fields.ts +++ b/packages/spec/src/data/search-fields.ts @@ -45,9 +45,14 @@ export const SEARCH_AUTO_EXCLUDED_FIELDS: ReadonlySet = new Set([ 'id', '_id', 'created', 'modified', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owner_id', 'organization_id', 'space', 'company_id', ]); +// [#13716] 'object', 'grid', 'geometry' and 'encrypted' were dropped from this +// set: none was a `FieldType` member at ANY commit (imported vocabulary, never +// renamed spellings — 'location', 'secret' and 'json' were already in both the +// enum and this set the day the set landed), so each could only ever match +// nothing. The [#13695] pin now holds every member to `FieldType`. 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', + 'json', 'image', 'file', 'avatar', 'vector', 'location', + 'secret', 'password', 'boolean', 'lookup', 'master_detail', ]); /** * [#6674] Field types with NO STORED COLUMN — the value is computed on read, so @@ -117,8 +122,10 @@ function autoDefaultFields(fields: Record, displayField // `SEARCH_AUTO_EXCLUDED_TYPES` is disjoint from both positive lists, so // every type it names already falls through the `return` below as `false` // — identically to a type in none of the three sets (`number`, `date`, …). - // Measured over the full 56-type domain (`FieldType` ∪ all three - // vocabularies), deleting this line moves not one resolution. So it is NOT + // Measured over the full domain of `FieldType` ∪ all three vocabularies + // (56 types when the guard landed; the union IS `FieldType` since #13716 + // dropped the last ghost members), deleting this line moves not one + // resolution. So it is NOT // load-bearing: adding a type to `SEARCHABLE_TEXTUAL_TYPES` does not also // require keeping it out of this set for the auto-default to reject it. // @@ -129,8 +136,8 @@ function autoDefaultFields(fields: Record, displayField // it the positive list wins and the type enters the auto-default AND, one // layer up, the #4254 ingress allow-list — so `$searchFields=` // flips from refused to ACCEPTED, the same widening #4483 closed for `id`. - // This set names `secret`, `password`, `encrypted` and `vector`: failing - // open there means a `$contains` scan over a masked or heavy column. + // This set names `secret`, `password` and `vector`: failing open there + // means a `$contains` scan over a masked or heavy column. // // The disjointness is not left to coincidence. `search-fields.test.ts` pins // all three vocabularies pairwise disjoint AND pins both resolution