You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 30af17e
Browse filesBrowse the repository at this point in the historyBrowse 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>
`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.
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.
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`.
0 commit comments