Skip to content

Commit 1a4c7f8

Browse files
fix(spec): filter-text-operator-declared-type-refused's control excludes JSON-stored fields and names the JSON-column door (#21234)
Fixes #21189 Clause-②: no ## What changes Text only, in one `@objectstack/spec` semantic migration entry: the `acceptanceCriteria` of `filter-text-operator-declared-type-refused` (protocol 18), plus its regenerated region of `packages/spec/src/migrations/registry.ts` and one `patch` changeset. Neither door's code moves. No entry id, `surface`, `replacement`, `reason`, conversion or refusal changes either. The old criteria named every text-valued field as the control that "must keep answering exactly as before". That list included `multiselect` / `checkboxes` / `tags` and lookup and `user` ids. The declared-type door this entry registers does still leave all of those alone. A second door judges a field by how it is **stored**, though, and it now refuses the prefix, suffix, case-folded and pattern operators on a JSON-stored column. So the old sentence named filters that answer `400` after the upgrade as the repair pass's control. The criteria now say: - **The control:** a text-valued field that is NOT stored as a JSON column. - **The JSON-stored population:** `multiselect` / `checkboxes` / `tags`; any field declared `multiple: true` (a multi-valued lookup or `user` among them); and, on a SQL deployment still inside the ADR-0104 dual-encoding window, a single-value file-class field. - **That door's refusal:** every text operator except the membership pair `$contains` / `$notContains`, with `INVALID_FILTER` / `400`. Its message names no declared type, so these refusals are outside this entry's repair list. - **The repair:** on a multi-valued field it is membership (`$contains` for one member, an `$or` of `$contains` for any-of), and no rewrite chooses it. A single-value file-class field answers text operators again once the deployment finishes the media-column move (the column step of `objectstack migrate files-to-references --apply`). ## What the text was checked against (`origin/main` at `99692286`, the JSON-column door as commit `2c1cef334` left it) - **The refused set.** `JSON_COLUMN_INCOMPATIBLE_OPERATORS` in `packages/core/src/utils/json-column-operator-refusal.ts` holds three groups. The equality family is `$eq` and `$ne` with their bare infix spellings. The scalar comparisons are the orderings, `$in` / `$nin` (plus the bare infix spellings) and `$between`. Since `2c1cef334` it also holds `$startsWith`, `$endsWith`, `$icontains`, `$like` and `$ilike`. `$contains` / `$notContains` / `$exists` / `$null` / `$empty` are absent. - **The refusal.** It is `INVALID_FILTER` / `400` on every face that reads the set. `driver-sql` raises it through `unsupportedFilterError`, and the objectql per-aggregation `filter` through `invalidFilterError`. `driver-memory`'s filter gate reads the same set too. - **The population.** The engine and `driver-memory` take `STRUCTURED_JSON_TYPES` together with `isMultiValueField`. `isMultiValueField` covers `MULTI_OPTION_TYPES` (`multiselect`, `checkboxes`, `tags`) and any `MULTI_CAPABLE_TYPES` member (`select`, `radio`, `lookup`, `user`, `file`, `image`) declared `multiple: true`. `driver-sql`'s `isJsonField` adds the single-value media family (`FILE_REFERENCE_TYPES`) when `mediaColumnIsJson()` answers true, which means the media columns have not moved. **The single-value media arm was measured, not inferred.** A throwaway SQLite test through `SqlDriver` (deleted, never committed) covered both arms of the move: | field | filter | media columns not moved | media columns moved | |:--|:--|:--|:--| | `text` | `$startsWith` | rows | rows | | single `select` | `$startsWith` | rows | rows | | single `user` | `$startsWith` | rows | rows | | single `file` | `$startsWith` / `$icontains` | `INVALID_FILTER 400` | rows | | `select`, `multiple: true` | `$startsWith` | `INVALID_FILTER 400` | `INVALID_FILTER 400` | | `select`, `multiple: true` | `$contains` | membership rows | membership rows | The triage direction listed the population as `multiselect` / `checkboxes` / `tags` and multi-valued lookup / `user`. The entry names the population the code holds, which is wider: every `multiple: true` field, and the single-value file classes while the media columns have not moved. Leaving those out would have left the same untrue control sentence for them. ## Tests and gates (all at `3a0bcb2d`, after merging `origin/main` at `62b90d74` through `scripts/pm/os-regen-merge.sh`) - **Package build and generated artifacts.** `pnpm --filter @objectstack/spec build`, then `pnpm --filter @objectstack/spec check:generated`, gave "All 15 generated artifacts are up to date". That covers `check:migration-registry`, `check:spec-changes` and `check:upgrade-guide`. The upgrade guide and `spec-changes.json` do not carry this entry's text, so only `registry.ts` moved. - **Package tests.** `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2` reported 596 files and 17457 tests passed (1 todo). - **Typecheck.** `pnpm --filter @objectstack/spec typecheck` exited 0. `check:test-typecheck` is OK: 52 files, 246 errors, 135 pinned signatures held. - **Derived gate families.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 82 commands from this tree. Of those, 81 ran with exit 0. `pnpm check:dual-build-cjs-loads` is NOT MEASURED: it exited 3 (PREREQUISITE NOT MET) because it loads every package's built output, and that needs a whole-repo build. CI runs it. The `--ran` reconciliation of the recorded exit codes accounts for all 82 families: 81 run, 1 NOT-MEASURED. - **Lint, narrowed to the changed files.** The run was `eslint --no-inline-config --format json` over the two changed `.ts` files. Its JSON output counts 2 files, 0 errors and 0 warnings. Both files fall under `eslint.config.mjs`'s `packages/**/*.{ts,tsx,mts,cts}` blocks. The narrowing is safe because the config never enables type-aware linting (no `parserOptions.project` and no typed rules, as its own header states). So this diff cannot change the verdict on any file it does not touch. - **The CLI pin that prints this text.** `packages/cli/test/migrate-meta-engine-guidance.test.ts` spawns the CLI, so it is the integration tier and is declared to CI. It asserts the printed block carries no tracker id. A static grep for `#` followed by 4–5 digits over the entry and the changeset found 0 hits. A positive control of the same regex matched. ## Acceptance notes - **Not filed: the single-value media arm with `$contains`.** On a SQL deployment whose media columns have not moved, `$contains` with a single-value file field's exact id answered zero rows. The JSON-column door's refusal on that same field prescribes `$contains`. This was measured at the driver only (the SQLite table above, `doc $contains 'fil_one'`), not through a public door, so it is not filed. Carrier: none. It is also why the entry gives that field the media-column move as its repair, not `$contains`. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7d9768 commit 1a4c7f8

3 files changed

Lines changed: 79 additions & 12 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the `filter-text-operator-declared-type-refused` migration entry's control no longer counts JSON-stored fields, and it names the separate door that refuses text operators there
6+
7+
Clause-②: no
8+
9+
**`filter-text-operator-declared-type-refused` (protocol 18).** The entry's acceptance criteria
10+
named every text-valued field, `multiselect` / `checkboxes` / `tags` and lookup and `user` ids
11+
included, as the control that "must keep answering exactly as before". The declared-type door this
12+
entry registers still leaves every text-valued type alone. A second door, though, judges a field by
13+
how it is STORED: on a column stored as JSON it now refuses `$startsWith`, `$endsWith`,
14+
`$icontains`, `$like` and `$ilike` with `INVALID_FILTER` / `400`, as it already refused the scalar
15+
comparisons there. So a stored filter that uses one of those operators on a multi-valued field
16+
answers that `400` after the upgrade, and the old sentence called it a control.
17+
18+
The criteria now say four things:
19+
20+
- The control is a text-valued field that is NOT stored as a JSON column.
21+
- The JSON-stored population is `multiselect` / `checkboxes` / `tags`, any field declared
22+
`multiple: true` (a multi-valued lookup or `user` among them), and, on a SQL deployment still
23+
inside the ADR-0104 dual-encoding window, a single-value file-class field.
24+
- That door refuses every text operator except the membership pair `$contains` / `$notContains`,
25+
and its refusal names no declared type, so it is outside this entry's repair list.
26+
- The repair on a multi-valued field is membership: `$contains` for one member, an `$or` of
27+
`$contains` for any-of. A single-value file-class field answers text operators again once the
28+
deployment finishes the media-column move (the column step of
29+
`objectstack migrate files-to-references --apply`).
30+
31+
Text only: no entry id, `surface`, `replacement`, `reason`, conversion or refusal changes, and
32+
neither door moves. `objectstack migrate meta` prints the corrected `verify:` line, and the
33+
generated migration registry carries the same text.

‎packages/spec/src/migrations/entries/semantic/18.filter-text-operator-declared-type-refused.ts‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,29 @@ export const entry: SemanticMigration = {
5050
+ 'refusals — each message names the filter key, the field\'s declared type and the '
5151
+ 'operator, which is the whole repair list. A filter re-authored onto a typed '
5252
+ 'operator returns the rows its author meant; one left as written keeps answering '
53-
+ '400, and NOTHING silently rewrites it. Filters over text-valued fields — '
54-
+ 'including `select` / `radio` codes, `multiselect` / `checkboxes` / `tags`, lookup '
55-
+ 'and `user` ids, `autonumber` and the file classes — are unaffected and must keep '
56-
+ 'answering exactly as before; that is the control which proves a repair pass did '
57-
+ 'not over-reach. A DIRECT driver call bypasses this door entirely and keeps '
53+
+ '400, and NOTHING silently rewrites it. This door judges a field by its declared '
54+
+ 'type alone, so it never refuses a text operator over a text-valued field — '
55+
+ '`select` / `radio` codes, `multiselect` / `checkboxes` / `tags`, lookup and `user` '
56+
+ 'ids, `autonumber` and the file classes. Over every such field that is not stored '
57+
+ 'as a JSON column (below), filters must keep answering exactly as before; that is '
58+
+ 'the control which proves a repair pass did not over-reach. A DIRECT driver call '
59+
+ 'bypasses this door entirely and keeps '
5860
+ 'answering the `FILTER_TEXT_CASES` stored-value row (a stored value that is not a string '
5961
+ 'never satisfies a positive text operator and satisfies `$notContains`), so a '
60-
+ 'driver-level test is not evidence about this migration in either direction.',
62+
+ 'driver-level test is not evidence about this migration in either direction. A '
63+
+ 'field stored as a JSON column is NOT that control, because a separate door judges '
64+
+ 'it by its storage rather than its declared type: `multiselect` / `checkboxes` / '
65+
+ '`tags`, any field declared `multiple: true` (a multi-valued lookup or `user` among '
66+
+ 'them) and, on a SQL deployment still inside the ADR-0104 dual-encoding window (its '
67+
+ 'media columns not yet moved), a single-value file-class field. That door refuses '
68+
+ 'every text operator there except the membership pair `$contains` / `$notContains` '
69+
+ '— `$startsWith`, `$endsWith`, `$icontains`, `$like` and `$ilike`, beside the '
70+
+ 'scalar comparisons it already refused — with an `INVALID_FILTER` 400 that names no '
71+
+ 'declared type, so a stored filter left on one of those operators there answers '
72+
+ 'that 400 after the upgrade and is outside this entry\'s repair list. On a multi-valued field its '
73+
+ 'repair is membership, which no rewrite chooses either: `$contains` for one member, '
74+
+ 'an `$or` of `$contains` for any-of. A single-value file-class field is not a '
75+
+ 'membership question: it answers text operators again once its deployment finishes '
76+
+ 'the media-column move (the column step of `objectstack migrate files-to-references '
77+
+ '--apply`).',
6178
};

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11814,14 +11814,31 @@ const step18: MigrationStep = {
1181411814
+ 'refusals — each message names the filter key, the field\'s declared type and the '
1181511815
+ 'operator, which is the whole repair list. A filter re-authored onto a typed '
1181611816
+ 'operator returns the rows its author meant; one left as written keeps answering '
11817-
+ '400, and NOTHING silently rewrites it. Filters over text-valued fields — '
11818-
+ 'including `select` / `radio` codes, `multiselect` / `checkboxes` / `tags`, lookup '
11819-
+ 'and `user` ids, `autonumber` and the file classes — are unaffected and must keep '
11820-
+ 'answering exactly as before; that is the control which proves a repair pass did '
11821-
+ 'not over-reach. A DIRECT driver call bypasses this door entirely and keeps '
11817+
+ '400, and NOTHING silently rewrites it. This door judges a field by its declared '
11818+
+ 'type alone, so it never refuses a text operator over a text-valued field — '
11819+
+ '`select` / `radio` codes, `multiselect` / `checkboxes` / `tags`, lookup and `user` '
11820+
+ 'ids, `autonumber` and the file classes. Over every such field that is not stored '
11821+
+ 'as a JSON column (below), filters must keep answering exactly as before; that is '
11822+
+ 'the control which proves a repair pass did not over-reach. A DIRECT driver call '
11823+
+ 'bypasses this door entirely and keeps '
1182211824
+ 'answering the `FILTER_TEXT_CASES` stored-value row (a stored value that is not a string '
1182311825
+ 'never satisfies a positive text operator and satisfies `$notContains`), so a '
11824-
+ 'driver-level test is not evidence about this migration in either direction.',
11826+
+ 'driver-level test is not evidence about this migration in either direction. A '
11827+
+ 'field stored as a JSON column is NOT that control, because a separate door judges '
11828+
+ 'it by its storage rather than its declared type: `multiselect` / `checkboxes` / '
11829+
+ '`tags`, any field declared `multiple: true` (a multi-valued lookup or `user` among '
11830+
+ 'them) and, on a SQL deployment still inside the ADR-0104 dual-encoding window (its '
11831+
+ 'media columns not yet moved), a single-value file-class field. That door refuses '
11832+
+ 'every text operator there except the membership pair `$contains` / `$notContains` '
11833+
+ '— `$startsWith`, `$endsWith`, `$icontains`, `$like` and `$ilike`, beside the '
11834+
+ 'scalar comparisons it already refused — with an `INVALID_FILTER` 400 that names no '
11835+
+ 'declared type, so a stored filter left on one of those operators there answers '
11836+
+ 'that 400 after the upgrade and is outside this entry\'s repair list. On a multi-valued field its '
11837+
+ 'repair is membership, which no rewrite chooses either: `$contains` for one member, '
11838+
+ 'an `$or` of `$contains` for any-of. A single-value file-class field is not a '
11839+
+ 'membership question: it answers text operators again once its deployment finishes '
11840+
+ 'the media-column move (the column step of `objectstack migrate files-to-references '
11841+
+ '--apply`).',
1182511842
},
1182611843
// The absent half of the decision-branch predicate rule. A SEPARATE entry from
1182711844
// `flow-predicate-slot-blank-string-refused` on purpose: that one keeps the

0 commit comments

Comments
 (0)