Repository navigation
Commit 45ce12a
Fixes #21066
Clause-②: yes (narrowing)
On a field the object declares JSON-stored (a `multiple: true` field,
`tags` / `multiselect` / `checkboxes`, or a structured-JSON type such as
`json`), `driver-memory` now refuses the scalar-comparison family that
`driver-sql`'s `where` refuses: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`,
`$lte`, `$between`, `$in`, `$nin` and implicit equality, whatever the
comparand, at any depth. The answer is `INVALID_FILTER` / 400 with the
same message. The operator set and the sentence are read from
`@objectstack/core` (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`,
`jsonColumnOperatorRefusalText`, homed by PR #21097). There is no third
copy. `$contains` / `$notContains` (membership), `$null`, `$exists` and
`$empty` keep answering.
## What was wrong (H1, measured at `origin/main` `670680e93` through
`engine.find`)
A real `ObjectQL` over `InMemoryDriver`, #21004's six rows (`owners` is
a `multiple: true` lookup, `tags` is a `tags` field). Every row
reproduces the card:
| `where` | before | now |
|:--|:--|:--|
| `owners` `$eq 'u1'` | `d1`, `d3` (per element) | 400 `INVALID_FILTER`
|
| `owners` `$in ['u1','u9']` | `d1`, `d3` | 400 |
| `owners` `$nin ['u1','u9']` | `d2`, `d4`, `d5`, `d6` | 400 |
| `owners` `$gt 'u1'` | `d1`, `d2`, `d3`, `d5` | 400 |
| `tags` `$gt 'red'` | `d3` | 400 |
| also: bare `{ owners: 'u1' }`, `$ne`, `$gte`, `$lt`, `$lte`,
`$between`, `tags $eq`, `{ owners: null }`, `$eq null`, `$ne null`, `$in
[]`, `$nin []` | rows, per element | 400 |
| controls: `owners $contains 'u1'` / `$notContains` / `$null` /
`$exists` / `$empty`, `title $in` | rows | unchanged rows |
The engine hands the driver the operators as written, except `$ne` /
`$nin`. Those arrive inside the spec's null-safe lowering (`$and` of
`$or` of `$null: true` and the operator). The gate walks `$and` / `$or`
/ `$not`, so that shape is refused too.
The analytics face (`MemoryAnalyticsService`) answered the same
per-element rows. Its SQL echo rendered `owners = 'u1'`, which matches
no row over the JSON text the SQL family stores. It now refuses in
`query()` and `generateSql()` alike.
## What changed
- `filter-refusal.ts`: the shape gate (`assertFilterConditionShape`)
takes an optional `FilterFieldDeclarations` (`isJsonStoredField`,
`reportWithheld`). It has two arms. Implicit equality on a declared
JSON-stored field is refused as `=`, bare. Any operator in the shared
set is refused AFTER the existing comparand-shape rules, which is
`driver-sql`'s order (comparand gate, then column-type gate). So an
array under `$eq` or a one-element `$between` still gets its own refusal
first. `jsonStoredFieldOperatorError` builds the error from the shared
text: this package's `unsupportedFilterError` envelope, with the
withheld diagnostic handed to `reportWithheld` (prefixed `At PATH:`)
before the throw.
- `memory-driver.ts`: `convertToMongoQuery` passes
`this.filterFieldDeclarations(object)`. The population is
`isJsonStoredField`, the predicate `$contains` already forks on
(`STRUCTURED_JSON_TYPES` or `isMultiValueField`). So the fields where
`$contains` asks membership are exactly the fields where the family is
refused. The diagnostic goes to the driver's logger at `warn`, the level
`driver-sql` uses for its withheld filter diagnostics. That keeps the
message's "the full diagnostic is in the server log" true here.
- `memory-analytics.ts`: `normalizeFilters` takes the cube. It judges a
`where` key (a cube member) by the field it maps to on the cube's table,
the same (table, field path) pair `filterContainsTest` reads. Its
diagnostic goes to the analytics service's own logger.
- `.changeset/21066-memory-json-column-family-refusal.md`:
`@objectstack/driver-memory` `minor`, BREAKING banner, `Clause-②: yes
(narrowing)`, one ADR-0087 marker `not-required
(no-migration-prescription)`. No registered id covers a filter operator
on a JSON-stored column. The one migration-registry entry that mentions
json columns (`cel-predicate-one-value-comparand-refused`) is the CEL
list-comparand surface, not this one.
## Hypotheses, measured
- **H1** holds: the table above.
- **H2.** The shared home is `@objectstack/core`'s
`json-column-operator-refusal.ts`, and both names are read. Before this
change `driver-memory` had NO withheld-diagnostic seam: every refusal it
raises (the `$null` / `$exists` non-boolean refusals included) names the
field in the message, and nothing in the package logged a diagnostic.
This change keeps the shared posture: the message names neither field
nor operator, and the diagnostic goes to the server log.
- **H3.** `driver-sql` decides a JSON column from `jsonFields`, filled
from `JSON_COLUMN_TYPES.has(type) or isMultiValueField(field)`.
`JSON_COLUMN_TYPES` is `STRUCTURED_JSON_TYPES` plus `MULTI_OPTION_TYPES`
plus the driver-internal `object` / `array` aliases. Memory's population
is the same predicate less those aliases and less a single-value media
field on an unmoved deployment (both recorded on `isJsonStoredField`).
On a schemaless direct call (an object never passed through
`syncSchema`), nothing is judged. Every operator answers per element as
before, as `SqlDriver.isJsonColumn` answers `false` for a table it was
never told about. Pinned. A field declared SCALAR (`text`) that holds an
array is not judged either.
- **H4.** `@objectstack/formula`'s `ORDERING_OPERATORS` docblock does
NOT declare a per-element reading for the query plane. It records a
non-alignment ("driver-memory's read, a frozen test driver, compares a
stored list element by element and keeps returning those rows ...
declared on #15104"). #15104 is the `$field` cross-field reference card,
shut as `not_planned` under the driver-memory investment freeze. It
rules nothing about the equality or ordering family on a stored list. So
this is a formula-plane record of observed behaviour, not a query-plane
contract, and no contract conflict stops the card. That docblock
sentence goes stale on declared fields once this lands (see Acceptance
notes).
- **H5.** #21009 widens the same shared set to the text operators. Both
gates here read the set live, and the new suite iterates
`JSON_COLUMN_INCOMPATIBLE_OPERATORS` intersected with this driver's
vocabulary, with a floor of the nine `$`-spellings. So once both land,
memory refuses `$startsWith` / `$endsWith` / `$icontains` on these
fields with no edit here, and the suite pins them. Whichever of the two
lands second merges `main` and checks the other's members on its face.
The suite's `$contains` control is outside #21009's scope.
## Pin sweep
- The ONE per-element pin the package carried on a declared field
flipped: `memory-20444-empty-operator.test.ts` had `{ tags: { $empty:
true, $ne: null } }` giving `r2`. It is now a refusal pin (`code` +
`status` + the shared message). The composition (`$empty` beside a
has-a-value sibling on one multi-value field) is kept through `$null:
false`, which answers `r2`. `driver-sql`/SQLite answers that row too,
and refuses the `$ne: null` spelling with the same body (measured on the
built driver).
- `memory-matcher-scalar-comparand-array-value.test.ts` pins per-element
answers on a column declared `text`. Those cells still hold, and a
header note now says the population there is a scalar-declared column.
- Repo-wide: only two tests outside this package bind the real driver
(`packages/runtime`'s two ruled consumers). Neither filters a
JSON-stored field. No other `INVALID_FILTER` pin moves.
## Tests (final head `13407b76f`)
- `pnpm --filter @objectstack/driver-memory exec vitest run
--maxWorkers=2`: **70 files, 1703 passed**. The first run after the
implementation, before any test edit: 69 files, 1 red of 1613, the
per-element pin flipped above.
- `pnpm --filter @objectstack/driver-memory run typecheck`: exit 0. `tsc
--listFiles` includes both edited test files.
- New `memory-21066-json-column-family-refusal.test.ts` (89 tests):
- the card's five rows;
- every family member on `owners` / `tags` / `meta` (`json`);
- ten shapes per field (bare, bare null, `$eq null`, `$ne null`, the
engine's `$ne` lowering, `$in []`, `$nin []`, under `$not`, an `$or`
branch after a holding one, `$eq` beside `$contains`);
- `count` / `findOne` / `updateMany` / `deleteMany` refusing with the
table untouched;
- the withheld message plus the logged `At filter.$or[1].owners.$gte:`
diagnostic;
- comparand-first ordering;
- eleven answered controls;
- the declaration boundary (undeclared object, scalar-declared column,
the gate with and without declarations);
- the analytics face, `query()` and `generateSql()`, including the
`cube.member` spelling, plus its log line and a `$contains` control.
- Ablations (`node scripts/ablation-replace.mjs`, wrap mode, run at
`de1fef341`; the two later merges touched no file in this package; each
restore proven blob == HEAD with `git diff HEAD` empty). The subjects
are this package's `src`, imported relatively, so no `dist` is involved:
- **A** `filterFieldDeclarations`' predicate forced to `() => false`:
**73 red / 37 green** of 110 across the new file and 20444. The 17 green
in the new file are exactly the answered controls, the
declaration-boundary trio, the premise, the comparand-first case and the
analytics `$contains` control.
- **B** the implicit-equality arm disabled: **7 red**, exactly the six
bare cases and the analytics bare case.
- **C** the operator arm disabled: **67 red**, every operator-based
refusal pin, the direct-gate test and the 20444 flip.
- Gate union, derived with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no paths; 7 changed
paths, working tree clean) at `13407b76f`: **60 derived, 60 run, every
one exit 0**. Reconciled with `--ran`: "60 derived famil(ies) accounted
for — 60 run, 0 NOT-MEASURED (a DERIVED zero — all 60 recorded an exit
code and none of them is 3)". The same 60 also ran all-zero at the
previous merge head `5b75fe461`. At `de1fef341`,
`check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, no dist
yet); it measured on both later heads.
- Driver conformance ledger (`node
scripts/check-driver-conformance.mjs`), before and after:
byte-identical. 50 covered cells, 0 DEBT, 0 exempt. The shared matrix
has no JSON-column or multi-value case-set, so this invariant is held by
the per-package pins, not the matrix.
## Acceptance notes
- **The class gains one method.**
`InMemoryDriver.filterFieldDeclarations` is tagged `@internal`. It is
not private only because the analytics face is another class.
`FilterFieldDeclarations` is not exported from the package root, but the
method does appear in the published `.d.ts`. #20984 graded the analogous
public `filterContainsTest` as a surface widening (`Clause-②: yes
(widening)`). The seat graded it so (5929927010): the line is `yes
(narrowing)`, with the semver (`minor`) and the ADR-0087 marker
unchanged.
- **Surface beyond the claim's list.** `memory-driver.ts` and
`memory-analytics.ts` are edited. The gate cannot see a declaration on
its own, so the plumbing is the minimum the direction needs, and the
analytics face calls the same gate. No open PR touched either file when
read before the first edit.
- **The AST comparison-node door** (`{ type: 'comparison', field:
'owners', operator: '=', value: 'u1' }`) still answers per element on a
declared field: `d1`, `d3`, measured on the built driver. No seam emits
that form (the engine and the protocol hand a driver a FilterCondition),
so it is reachable only by a direct driver call. Left alone.
- **The shared sentence's mechanism clause** ("a field this driver
stores as a JSON TEXT column", "$in/$eq matched nothing") is
`driver-sql`'s, and is literally untrue of this driver and of the
engine's per-aggregation face. The prescription (`$contains`, an `$or`
of `$contains`) is right on all three. Inherited as #21007 shipped it.
#21009 is the PR that next edits the shared home.
- `@objectstack/formula`'s `ORDERING_OPERATORS` docblock
("driver-memory's read ... keeps returning those rows") is now true only
of undeclared objects. It is a comment, and no claim holds that file.
- **Tooling.** With the `turbo` 2.10.10 to 2.11.5 bump now on `main`,
every repo-scoped turbo run in an agent session appends a managed
"turborepo-agent-rules" block (an HTML-comment-delimited section) to
`AGENTS.md`. These include `pnpm exec turbo run build`, `pnpm
check:type-check-debt`, `check:query-options-erasure` and
`check:slot-lookup`. It happened repeatedly in this worktree and was
restored each time, and every gate derivation above was taken on a clean
tree; this PR does not touch `AGENTS.md`. Tracked as #21146 (PR #21151).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent ebdb6f2 commit 45ce12a
7 files changed
Lines changed: 565 additions & 10 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
49 | 54 | | |
50 | 55 | | |
51 | 56 | | |
| |||
821 | 826 | | |
822 | 827 | | |
823 | 828 | | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
| 873 | + | |
| 874 | + | |
| 875 | + | |
| 876 | + | |
| 877 | + | |
| 878 | + | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + | |
| 899 | + | |
| 900 | + | |
| 901 | + | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
824 | 914 | | |
825 | 915 | | |
826 | 916 | | |
| |||
890 | 980 | | |
891 | 981 | | |
892 | 982 | | |
| 983 | + | |
| 984 | + | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
| 988 | + | |
| 989 | + | |
| 990 | + | |
| 991 | + | |
| 992 | + | |
| 993 | + | |
893 | 994 | | |
894 | 995 | | |
895 | 996 | | |
896 | 997 | | |
897 | 998 | | |
| 999 | + | |
898 | 1000 | | |
899 | 1001 | | |
900 | 1002 | | |
| |||
905 | 1007 | | |
906 | 1008 | | |
907 | 1009 | | |
908 | | - | |
| 1010 | + | |
909 | 1011 | | |
910 | 1012 | | |
911 | 1013 | | |
912 | 1014 | | |
913 | 1015 | | |
914 | 1016 | | |
915 | | - | |
| 1017 | + | |
916 | 1018 | | |
917 | 1019 | | |
918 | 1020 | | |
919 | | - | |
| 1021 | + | |
920 | 1022 | | |
921 | 1023 | | |
922 | 1024 | | |
| |||
936 | 1038 | | |
937 | 1039 | | |
938 | 1040 | | |
| 1041 | + | |
939 | 1042 | | |
940 | 1043 | | |
941 | 1044 | | |
942 | 1045 | | |
943 | 1046 | | |
944 | 1047 | | |
945 | | - | |
| 1048 | + | |
| 1049 | + | |
| 1050 | + | |
| 1051 | + | |
| 1052 | + | |
| 1053 | + | |
| 1054 | + | |
| 1055 | + | |
| 1056 | + | |
| 1057 | + | |
| 1058 | + | |
| 1059 | + | |
946 | 1060 | | |
947 | 1061 | | |
948 | 1062 | | |
| |||
1028 | 1142 | | |
1029 | 1143 | | |
1030 | 1144 | | |
| 1145 | + | |
| 1146 | + | |
| 1147 | + | |
| 1148 | + | |
| 1149 | + | |
| 1150 | + | |
| 1151 | + | |
| 1152 | + | |
| 1153 | + | |
| 1154 | + | |
| 1155 | + | |
| 1156 | + | |
1031 | 1157 | | |
1032 | 1158 | | |
1033 | 1159 | | |
| |||
Lines changed: 21 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| 31 | + | |
31 | 32 | | |
32 | 33 | | |
33 | 34 | | |
| |||
65 | 66 | | |
66 | 67 | | |
67 | 68 | | |
68 | | - | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
69 | 75 | | |
70 | 76 | | |
71 | 77 | | |
| |||
150 | 156 | | |
151 | 157 | | |
152 | 158 | | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
153 | 173 | | |
154 | 174 | | |
155 | 175 | | |
| |||
0 commit comments