Skip to content

Commit 30af17e

Browse files
fix(core,driver-sql,driver-turso): a single-value file field inside the ADR-0104 window is told to finish the media-column move, not to use $contains (#21282)
Fixes #21236 Clause-②: no ## What changed On a SQL deployment still inside the ADR-0104 dual-encoding window (its media columns not yet moved), `driver-sql` stores a single-value file-class field (`file`, `image`, `avatar`, `video`, `audio`) as a JSON column, and the JSON-column gate refuses every operator in `JSON_COLUMN_INCOMPATIBLE_OPERATORS` there. That refusal prescribed `$contains`, which answers no rows on that column. It now prescribes the media-column move, in the words of the migration entry `filter-text-operator-declared-type-refused` (landed `1a4c7f82`): > Such a field is not a membership question: it answers these operators again once this deployment finishes the media-column move (the column step of `objectstack migrate files-to-references --apply`). The refused operators and the fields they are refused on do not change. Only the words for that one class of column change, so this is `Clause-②: no`. The multi-valued `$contains` words are the control and are byte-identical: the existing hash pins did not move. - **`@objectstack/core`**: `jsonColumnOperatorRefusalText(field, op, bare, fieldClass = 'multi-value-or-json')`, and `JsonColumnFieldClass` is now exported. The `'single-value-media'` class has its own reason ("at a single-value file-class field still stored as JSON") and the move as its remedy. It keeps the presence clause, because `$null` and `$empty` were measured answering on that column. It never names `$contains`. The builder reads no declaration: each face passes the class (H4). - **`@objectstack/driver-sql`**: new `SqlDriver.jsonColumnFieldClass(table, localField)`. It answers `undefined` unless `isJsonColumn` is true, `'single-value-media'` when `mediaFields` also names the field, and `'multi-value-or-json'` otherwise. It does not ask about the window again. A single-value media field is in `jsonFields` only because `mediaColumnIsJson()` answered true when it registered (`isJsonField`), so the predicate triage named is still the only window test. `assertOperatorAppliesToColumn` passes the class to the builder. - **`@objectstack/driver-turso`**: the remote transport's `JsonColumnResolver` now answers the column's class (from `SqlDriver.jsonColumnFieldClass`) instead of yes or no. See the face table for why. ## Per-face conclusions (H2) | Face | Conclusion | Evidence | |---|---|---| | `driver-sql` `where` (`assertOperatorAppliesToColumn`) | changed | `sql-driver-21236-single-value-media-refusal-remedy.test.ts` | | `driver-turso` local transport | changed (inherits `SqlDriver`) | `turso-local-remote-single-value-media-refusal-parity.test.ts` | | `driver-turso` remote transport (`RemoteTransport.buildWhereSQL`) | changed (in-place, see below) | Measured before the change on the libsql stub: `$startsWith` and `$eq` on a single `file` field were refused with the `$contains` words on both transports, and `$contains: 'fil_one'` answered no rows on both. Remote mode never moves its media columns: the resolver is never asked there, and `refuseRemoteMediaColumnMove` refuses the column step. So such a field is a JSON column there on every deployment. If remote were left alone, one `TursoDriver` would give two different sentences for one filter. The same parity file pins it. | | `objectql` per-aggregation `filter` (`having-filter.ts`) | already conformant | `declaredJsonStoredFields` (`having-filter.ts` lines 966-976) is `STRUCTURED_JSON_TYPES` plus `isMultiValueField`. A single-value file-class field is never JSON-stored there, so it never meets this refusal, and it keeps the default class. | | `driver-memory` filter gate | already conformant | `isJsonStoredField` (`memory-driver.ts` lines 2391-2395) leaves out single-value media on purpose ("this driver stores the bare id, the moved end-state"). That driver has no window. | The remote-face change is a bounded in-place fix: it is the same defect class, a mechanical one-parameter thread-through of the shape the local face sets, it touches no file another open claim names, and it adds no new gate family. My `driver-sql` change would otherwise have broken the two-face parity the remote face's docblock promises. ## Dispatch hypotheses - **H1 confirmed** at base `ee42f00e`: the builder is at `json-column-operator-refusal.ts:229` and `containsRemedy` at `:174`. `mediaColumnIsJson()` is at `sql-driver.ts:19944` and `isJsonField` at `:19969`. - **H2 measured**: see the table above. - **H3 held**: the new withheld message is 485 characters. The pin runs it through `truncateClientMessage` for every refused spelling, with no retyped 500. The diagnostic is whole on the wire for a field name of up to 91 characters: pinned at 91, and shown cut at 92. - **H4 chosen**: an optional `fieldClass` parameter that each face picks from its own registries. The sentence has one source and no copy. ## Pins (triage's three) 1. **Inside the window**: on each of the five single-value file-class types, every refused `$` operator of `FILTER_OPERATORS`, and the bare spelling, answers `INVALID_FILTER` / 400. Message and diagnostic both equal the builder's `'single-value-media'` text, and neither names `$contains`. 2. **Control**: a `multiple: true` file field on the same driver keeps the default words byte for byte, and `$contains` answers its membership. 3. **Moved** (`fileColumnsMoved: true`): `$startsWith`, `$eq` and the bare spelling answer rows on all five types, and the multi-valued control is still refused. ## Verification The source tree is identical at `efa7963079` and at HEAD `ea413a0a14`: the one commit between them edits only `.changeset/21236-driver-turso-single-value-media-refusal-remedy.md`. The suites below ran at `efa7963079`. The ablation, the reverse verification and the gates ran at `ea413a0a14`. - **Typecheck**: `pnpm --workspace-concurrency=2 --filter @objectstack/core --filter @objectstack/driver-sql --filter @objectstack/driver-turso --filter @objectstack/objectql --filter @objectstack/driver-memory run typecheck` gave `VERDICT command-exit 0`, and all five printed Done. `tsc --listFiles` counts the new driver-sql pin (1) and both turso test files (2). Core's tests are typechecked by its own `check:test-typecheck` step. - **Tests**: - `@objectstack/core`: 76 files, 2156 tests passed. - `@objectstack/driver-sql`: 214 files passed and 11 skipped, 3586 tests passed and 200 skipped. The skips are the unprovisioned live PG/MySQL cells, which CI's Temporal Conformance job runs. This refusal fires before any SQL is built. - `@objectstack/driver-turso`: 88 files, 2365 tests passed and 33 skipped. - `@objectstack/objectql` (`--project local`): 360 files, 7082 tests passed. - `@objectstack/driver-memory`: 70 files, 1718 tests passed. - `@objectstack/rest` `src/aggregation-filter-json-column-refusal.test.ts`, which compares against the builder's output: 68 passed and 136 skipped. - **Ablation** (`scripts/ablation-replace.mjs`, with a trap restore): the mutation makes `jsonColumnFieldClass` always answer `'multi-value-or-json'`. The anchor went from 1 hit to 0, and the blob from `38adce8f1adf` to `a05b0c868c36`. After `pnpm --filter @objectstack/driver-sql build`, `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.mjs`. The driver-sql pin went red on its 5 per-type section-1 cases (11 still green: the control, the moved arm and the presence cases). The turso parity pin went red on 15 cases, and the control stayed green. **Restore leg**: the blob is back to the HEAD hash and `git diff HEAD` is empty. After a rebuild, the preflight `--absent` read clean on both dist and tree, and both pins passed 16/16. - **Reverse verification** (cross-package type): the turso resolver injection was given the old yes/no answer (`this.isJsonColumn(object, field)`). `pnpm --filter @objectstack/driver-turso run typecheck` exited 2 with `turso-driver.ts(1818,9): error TS2322: Type 'boolean' is not assignable to type 'JsonColumnFieldClass | undefined'`. The restore showed the blob equal to HEAD and `git diff HEAD` empty. - **`pnpm check:driver-conformance`**: before (BASE `ee42f00e`, in a throwaway detached worktree) exit 0, "50 covered cell(s), 0 in the DEBT ledger". After (HEAD) exit 0, with the same reading. - **Gates**: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 65 commands. 64 exited 0. `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET, because the whole-workspace `dist/` is absent. That makes it **NOT MEASURED**, and CI's Build Core runs it. As a narrower check, `require()` of the CJS entry of core, driver-sql and driver-turso loads. Reconciling with `--ran` gave 65 derived, 64 run, 1 NOT-MEASURED and 0 UNRUN. - **`check:doc-authoring`**: green on this tree. Against `origin/main`'s newer `doc-authoring-prose-id.baseline.json` it reds, but only in `packages/qa/dogfood/test/authz-conformance.matrix.ts` (46 lines), a file upstream rewrote together with that baseline and that this branch does not touch. ## Acceptance notes - **Turso remote transport: the move is not available there.** The remote transport refuses to plan the column step (`NOT_IMPLEMENTED` / 501), and it never reads a completed move. On a remote deployment, then, the new words lead to a loud refusal from the command, where `$contains` used to answer zero rows silently. A single-value file-class field on a remote deployment still has no transport-local way to answer `$eq`, `$in` or the text operators. That is the existing remote media-move gap, unchanged here. The turso changeset says so. Carrier: none. - **Changesets and semver: `@objectstack/driver-turso` is `minor`; `@objectstack/core` and `@objectstack/driver-sql` are `patch`.** `RemoteTransport.setJsonColumnResolver` is a published export, and its resolver's return type is replaced (boolean becomes `JsonColumnFieldClass | undefined`), so a host's boolean resolver no longer compiles. That is why the turso changeset is `minor`, per the seat's contract review ruling. `Clause-②: no` stands, because no accept set moves. `TursoDriver` is the only producer in the tree. The changeset gives the mapping (`true` becomes `'multi-value-or-json'`, `false` becomes `undefined`). The bump is commit `09243923ba`, which touches only that changeset. - **Base**: the branch sits on `ee42f00e`. The upstream commits since then do not touch any package this diff touches. The merge queue rebuilds on current `main`. - **Claim file surface**: the claim names `core` and `driver-sql`. The `driver-turso` files (`remote-transport.ts`, `turso-driver.ts`, two tests) are the in-place fix described above. - **Not touched**: the multi-valued `$contains` text (the control), and `nowColumnDefault` / `create`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c2cd651 commit 30af17e

11 files changed

Lines changed: 645 additions & 23 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
`jsonColumnOperatorRefusalText` takes an optional fourth argument: the class of JSON column the refused operator met, `JsonColumnFieldClass` (now exported). `'multi-value-or-json'` is the default, and its words are unchanged. `'single-value-media'` words the refusal for a single-value file-class field that a SQL deployment still stores as a JSON column.
6+
7+
Clause-②: no
8+
9+
A single-value file-class field (`file`, `image`, `avatar`, `video`, `audio`) is stored as a JSON column only on a deployment inside the ADR-0104 dual-encoding window, whose media columns have not moved. There it holds one JSON string, so `$contains` with the field's exact id answers no rows. That class's refusal no longer prescribes `$contains`. It says that the field answers these operators again once the deployment finishes the media-column move (the column step of `objectstack migrate files-to-references --apply`), and it still names `$null` / `$empty` for "no value". The message stays under the REST envelope's 500-character bound. Which operators are refused, and on which fields, does not change.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
On a deployment whose media columns have not moved, the JSON-column filter refusal on a single-value file-class field (`file`, `image`, `avatar`, `video`, `audio`) now names the repair that works there: the media-column move, not `$contains`.
6+
7+
Clause-②: no
8+
9+
The filter is still refused with `INVALID_FILTER` / 400, for the same operators as before (`$eq`, `$in`, `$startsWith`, `$icontains`, the orderings and the rest of that set, and the bare `{ field: value }` spelling). Before, the refusal told the caller to use `$contains`, the membership repair for a multi-valued field. On a single-value file-class field `$contains` with the field's exact id answers no rows. The refusal now says that the field answers these operators again once the deployment finishes the media-column move (the column step of `objectstack migrate files-to-references --apply`), and it still names `$null` / `$empty`, which answer there. A multi-valued field keeps the `$contains` words, byte for byte. Once the media columns have moved, these filters are not refused, as before.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
---
4+
5+
Both transports now word the JSON-column filter refusal on a single-value file-class field (`file`, `image`, `avatar`, `video`, `audio`) the way `@objectstack/driver-sql` does: the media-column move, not `$contains`, which answers no rows on that field.
6+
7+
Clause-②: no
8+
9+
The local transport inherits the new words from `SqlDriver`. The remote transport refuses in its own filter compiler, and now reads the same class from the driver, so one filter gets one message on both transports. The refused operators and fields do not change. Remote mode never moves its media columns, so a single-value file-class field is a JSON column there on every deployment; the remote transport refuses to plan the column step of `objectstack migrate files-to-references` (`NOT_IMPLEMENTED` / 501), as before, so on that transport the prescribed move is not yet available.
10+
11+
`RemoteTransport.setJsonColumnResolver` now takes a resolver that answers the column's class (`JsonColumnFieldClass`, from `@objectstack/core`), or `undefined` for a column that is not JSON, in place of `true` / `false`. `TursoDriver` supplies it. A host that calls the method itself returns `'multi-value-or-json'` where it returned `true`, and `undefined` where it returned `false`. That replaces the setter's published parameter type, so a host resolver that returns a boolean no longer compiles: a host that injects its own resolver updates its signature, which is why this release is `minor`.

‎packages/core/src/utils/json-column-operator-refusal.test.ts‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,22 @@
2828
*
2929
* A deliberate change of wording updates the hashes in the PR that makes it —
3030
* and then reaches every face alike, which is the point of the move.
31+
*
32+
* [#21236] The second class of JSON column, a single-value file-class field
33+
* inside the ADR-0104 window, has its own reason and repair (the media-column
34+
* move), pinned the same three ways at the foot of this file. The first
35+
* class's words are the control: its hashes above did not move, and the
36+
* default class is the first class, byte for byte.
3137
*/
3238

3339
import { describe, it, expect } from 'vitest';
3440
import { createHash } from 'node:crypto';
3541
import { truncateClientMessage } from '@objectstack/types';
36-
import { JSON_COLUMN_INCOMPATIBLE_OPERATORS, jsonColumnOperatorRefusalText } from './json-column-operator-refusal.js';
42+
import {
43+
JSON_COLUMN_INCOMPATIBLE_OPERATORS,
44+
jsonColumnOperatorRefusalText,
45+
type JsonColumnFieldClass,
46+
} from './json-column-operator-refusal.js';
3747

3848
const sha256 = (text: string): string => createHash('sha256').update(text, 'utf8').digest('hex');
3949

@@ -151,3 +161,75 @@ describe('[#21067] jsonColumnOperatorRefusalText — whole on the wire, and true
151161
expect(truncateClientMessage(diagnostic)).toBe(diagnostic);
152162
});
153163
});
164+
165+
describe('[#21236] jsonColumnOperatorRefusalText — a single-value file-class field inside the ADR-0104 window', () => {
166+
const MEDIA: JsonColumnFieldClass = 'single-value-media';
167+
/** Every spelling that gets this refusal: each member of the set, and the bare one. */
168+
const SPELLINGS: ReadonlyArray<readonly [op: string, bare: boolean]> = [
169+
...[...JSON_COLUMN_INCOMPATIBLE_OPERATORS].map((op) => [op, false] as const),
170+
['=', true],
171+
];
172+
/** The move, as the migration entry `filter-text-operator-declared-type-refused` words it. */
173+
const MOVE =
174+
'Such a field is not a membership question: it answers these operators again once this deployment ' +
175+
'finishes the media-column move (the column step of `objectstack migrate files-to-references --apply`).';
176+
177+
it('the words, by hash: one message for every operator, and a diagnostic per spelling', () => {
178+
const MESSAGE = { sha: 'd76a61be45a1f2200179f7282e4cbe50685ad32f65124f55fc79eb7a2876fd22', length: 485 };
179+
for (const [op, bare, diagnostic] of [
180+
['$startsWith', false, { sha: '9ee94859d50fc05f124ee2c1620c20d140c9572d2eb680bab3a45205a0b66de6', length: 418 }],
181+
['$eq', false, { sha: '8e976c33c09a3c91ba135179caf47497ad1fbe7d1b8bc8c29ae5363512af83d8', length: 402 }],
182+
['=', true, { sha: '40f4606c544acab304594079b6a8c9ded6fabfb7b2a9e037932e96aa0ca5e988', length: 414 }],
183+
] as const) {
184+
const text = jsonColumnOperatorRefusalText('attachment', op, bare, MEDIA);
185+
expect({ sha: sha256(text.message), length: text.message.length }, op).toEqual(MESSAGE);
186+
expect({ sha: sha256(text.diagnostic), length: text.diagnostic.length }, op).toEqual(diagnostic);
187+
}
188+
});
189+
190+
it('prescribes the media-column move and the presence spellings, and never $contains', () => {
191+
for (const [op, bare] of SPELLINGS) {
192+
const { message, diagnostic } = jsonColumnOperatorRefusalText('attachment', op, bare, MEDIA);
193+
for (const text of [message, diagnostic]) {
194+
expect(text, op).toContain('at a single-value file-class field still stored as JSON.');
195+
expect(text, op).toContain(MOVE);
196+
expect(text, op).toContain('For no value, use "$null" or "$empty".');
197+
expect(text, op).not.toContain('$contains');
198+
expect(text, op).not.toContain('which it cannot test for one member');
199+
}
200+
expect(message.startsWith('A constraint in this filter WAS NOT APPLIED: ')).toBe(true);
201+
expect(message.endsWith(
202+
'The field and the operator are withheld from the message; the full diagnostic is in the server log.',
203+
)).toBe(true);
204+
expect(message, op).not.toContain('attachment');
205+
expect(diagnostic, op).toContain(`it aims "${op}", a scalar comparison or text operator, at`);
206+
}
207+
});
208+
209+
it('the message passes the REST envelope\'s bound unchanged, for every refused spelling', () => {
210+
for (const [op, bare] of SPELLINGS) {
211+
const { message } = jsonColumnOperatorRefusalText('attachment', op, bare, MEDIA);
212+
expect(truncateClientMessage(message), op).toBe(message);
213+
}
214+
});
215+
216+
it('the diagnostic is whole on the wire for a 91-character field name, the bound the module docblock states', () => {
217+
const name = 'f'.repeat(91);
218+
for (const [op, bare] of SPELLINGS) {
219+
const { diagnostic } = jsonColumnOperatorRefusalText(name, op, bare, MEDIA);
220+
expect(truncateClientMessage(diagnostic), op).toBe(diagnostic);
221+
}
222+
const past = jsonColumnOperatorRefusalText(`${name}f`, '$startsWith', false, MEDIA).diagnostic;
223+
expect(truncateClientMessage(past)).not.toBe(past);
224+
});
225+
226+
it('the control: the default class IS the multi-value class, and its words are not the media class\'s', () => {
227+
for (const [op, bare] of SPELLINGS) {
228+
const byDefault = jsonColumnOperatorRefusalText('members', op, bare);
229+
const named = jsonColumnOperatorRefusalText('members', op, bare, 'multi-value-or-json');
230+
expect(named, op).toEqual(byDefault);
231+
expect(byDefault.message, op).toContain('{ "FIELD": { "$contains": "a" } }');
232+
expect(jsonColumnOperatorRefusalText('members', op, bare, MEDIA).message, op).not.toBe(byDefault.message);
233+
}
234+
});
235+
});

‎packages/core/src/utils/json-column-operator-refusal.ts‎

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,28 @@
5050
* and then the any-of example last, so a longer name pushes those out first;
5151
* the any-of example survives up to 36 characters.
5252
*
53+
* ## Two classes of JSON column, two repairs
54+
*
55+
* [#21236] The membership repair is right for a field that IS a list or a
56+
* structured value, which is every JSON column the engine and `driver-memory`
57+
* meet. The SQL family meets one more: on a deployment still inside the
58+
* ADR-0104 dual-encoding window (its media columns not yet moved),
59+
* `driver-sql` stores a SINGLE-VALUE file-class field (`file`, `image`,
60+
* `avatar`, `video`, `audio`) as a JSON column too, holding one JSON string.
61+
* There is no member to find there: measured on SQLite through `SqlDriver`,
62+
* `$contains` with the field's exact id answered no rows, while the same
63+
* `$startsWith` answered rows once the columns had moved. So that class gets
64+
* its own reason and its own repair, the media-column move, in the words the
65+
* migration entry `filter-text-operator-declared-type-refused` gives it
66+
* (`@objectstack/spec`). The face knows the class and passes it
67+
* ({@link JsonColumnFieldClass}); this module never reads a declaration. The
68+
* operator set, the presence spellings and the "withheld" sentence are the
69+
* same for both classes. Only the SQL family meets the second class, so its
70+
* reason may name the storage the window leaves behind, where the first
71+
* class's must not. Its message is held under the same bound by the same pin;
72+
* its diagnostic names the field once, and is whole on the wire for a field
73+
* name of up to 91 characters.
74+
*
5375
* ## The other half of the JSON column's contract
5476
*
5577
* `$contains` is the membership spelling on such a column (`FILTER_OPERATORS`'
@@ -126,6 +148,28 @@ export const JSON_COLUMN_INCOMPATIBLE_OPERATORS: ReadonlySet<string> = new Set([
126148
'$startsWith', '$endsWith', '$icontains', '$like', '$ilike',
127149
]);
128150

151+
/**
152+
* [#21236] Which class of JSON column a refused operator met. The class decides
153+
* the reason the refusal gives and the repair it prescribes.
154+
*
155+
* - `'multi-value-or-json'`: a multi-value field (`multiple: true`, or an
156+
* inherently multi-value option type) or a structured-JSON type. Such a
157+
* column is JSON on every deployment, and its repair is membership:
158+
* `$contains`, or an `$or` of `$contains` for any-of.
159+
* - `'single-value-media'`: a single-value file-class field that a SQL
160+
* deployment inside the ADR-0104 dual-encoding window still stores as a JSON
161+
* column. The column holds one JSON string, so this is not a membership
162+
* question. Its repair is the media-column move, after which the column
163+
* holds the bare id and these operators answer again.
164+
*
165+
* The face decides the class from its own registries; the builder only words
166+
* it. `driver-sql`, and `driver-turso`'s two faces that ask its registries, are
167+
* the only faces that meet the second class. The engine's per-aggregation
168+
* `filter` and `driver-memory` never store a single-value file-class field as
169+
* JSON, so they meet only the first, which is the default.
170+
*/
171+
export type JsonColumnFieldClass = 'multi-value-or-json' | 'single-value-media';
172+
129173
/** The two texts of one JSON-column refusal — see {@link jsonColumnOperatorRefusalText}. */
130174
export interface JsonColumnOperatorRefusalText {
131175
/**
@@ -150,12 +194,18 @@ export interface JsonColumnOperatorRefusalText {
150194
* prints them — see the module docblock. Shared by both texts, so the message
151195
* and the diagnostic cannot come to give two reasons; the diagnostic passes
152196
* `op` and so names the operator, the bare spelling's as `=`.
197+
*
198+
* [#21236] The field's half of the reason is its class's. A single-value
199+
* file-class field is not a field "it cannot test for one member": it has no
200+
* members, so its reason says what the window left behind instead.
153201
*/
154-
function refusalReason(op?: string): string {
202+
function refusalReason(fieldClass: JsonColumnFieldClass, op?: string): string {
155203
const operator = op === undefined
156204
? 'a scalar comparison or text operator'
157205
: `"${op}", a scalar comparison or text operator,`;
158-
return `it aims ${operator} at a multi-value or JSON field, which it cannot test for one member.`;
206+
return fieldClass === 'single-value-media'
207+
? `it aims ${operator} at a single-value file-class field still stored as JSON.`
208+
: `it aims ${operator} at a multi-value or JSON field, which it cannot test for one member.`;
159209
}
160210

161211
/**
@@ -179,6 +229,30 @@ function containsRemedy(name: string): string {
179229
);
180230
}
181231

232+
/**
233+
* [#21236] The prescription for a single-value file-class field whose column is
234+
* still JSON: the media-column move, which makes the column hold the bare id.
235+
* It agrees with the migration entry `filter-text-operator-declared-type-refused`
236+
* (`@objectstack/spec`), which says that such a field "is not a membership
237+
* question: it answers text operators again once its deployment finishes the
238+
* media-column move". The equality and ordering operators answer again too,
239+
* hence "these operators". `$contains` is not named: on that column it answers
240+
* no rows.
241+
*
242+
* The presence clause stays. `$null` and `$empty` are outside the refused set,
243+
* and they answer on that column: `$empty` takes the spec's null-only row for
244+
* a file-class type, so both are `IS NULL`.
245+
*/
246+
const MEDIA_COLUMN_MOVE_REMEDY =
247+
'Such a field is not a membership question: it answers these operators again once this ' +
248+
'deployment finishes the media-column move (the column step of ' +
249+
`\`objectstack migrate files-to-references --apply\`). ${PRESENCE_REMEDY}`;
250+
251+
/** [#21236] The prescription for `fieldClass`, spelled with `name` in the field position where it has one. */
252+
function refusalRemedy(fieldClass: JsonColumnFieldClass, name: string): string {
253+
return fieldClass === 'single-value-media' ? MEDIA_COLUMN_MOVE_REMEDY : containsRemedy(name);
254+
}
255+
182256
/**
183257
* [#7398] The words of the refusal: a scalar-comparison operator met a field
184258
* stored as JSON TEXT, so the comparison can never mean what the caller wrote.
@@ -225,20 +299,29 @@ function containsRemedy(name: string): string {
225299
* withheld; it is now 486, with the presence spellings a `null` comparand
226300
* needs (see `PRESENCE_REMEDY`). The mechanism above stays here, where the
227301
* next author reads it.
302+
*
303+
* [#21236] `fieldClass` is the class of JSON column the operator met
304+
* ({@link JsonColumnFieldClass}). The default, `'multi-value-or-json'`, gives
305+
* the words above unchanged. `'single-value-media'` gives the media-column
306+
* move as the repair: on a single-value file-class field inside the ADR-0104
307+
* window, `$contains` answered no rows. A face that can hold such a field as
308+
* JSON passes the class it read from its own registries.
228309
*/
229310
export function jsonColumnOperatorRefusalText(
230311
field: string,
231312
op: string,
232313
bare: boolean,
314+
fieldClass: JsonColumnFieldClass = 'multi-value-or-json',
233315
): JsonColumnOperatorRefusalText {
234316
const subject = bare
235317
? `The bare equality spelling { "${field}": value }`
236318
: `Operator "${op}" on field "${field}"`;
237319
return {
238320
message:
239-
`A constraint in this filter WAS NOT APPLIED: ${refusalReason()} ${containsRemedy('FIELD')} ` +
321+
`A constraint in this filter WAS NOT APPLIED: ${refusalReason(fieldClass)} ` +
322+
`${refusalRemedy(fieldClass, 'FIELD')} ` +
240323
`The field and the operator are withheld from the message; the full diagnostic is in the ` +
241324
`server log.`,
242-
diagnostic: `${subject} WAS NOT APPLIED: ${refusalReason(op)} ${containsRemedy(field)}`,
325+
diagnostic: `${subject} WAS NOT APPLIED: ${refusalReason(fieldClass, op)} ${refusalRemedy(fieldClass, field)}`,
243326
};
244327
}

0 commit comments

Comments
 (0)