Skip to content

Commit b392043

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6437-dropped-fields-reason-vocabulary
# Conflicts: # packages/spec/src/contracts/data-engine.ts
2 parents 3dbfc3a + 3f8817a commit b392043

64 files changed

Lines changed: 4026 additions & 306 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Align two schema `.describe()` strings with their measured acceptance faces (docs-only; no acceptance change — every previously-valid input is judged byte-identically):
6+
7+
- `GroupingConfigSchema.fields` no longer claims "(supports up to 3 levels)". The gate is `.min(1)` with no upper bound, nothing downstream enforces a cap, and the grid renderer recurses over all configured levels — the describe now states the shape instead: array order is nesting order (first entry outermost), at least one field. (#7084)
8+
- `NotifyConfigSchema.sourceObject` / `sourceId` no longer say "Requires sourceId." / "Requires sourceObject.". The schema deliberately accepts the half-specified pair — the executor drops it at execute time so the inbox never renders a dead link (the module JSDoc's recorded contract) — and the describes now state that tolerance. (#7085)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/trigger-record-change': patch
3+
---
4+
5+
record-change trigger: drop the `ctx.__previous` stash fallback — read the engine's declared `ctx.previous` only
6+
7+
Behaviour is unchanged: the limb guarded against a producer that no longer
8+
exists. `plugin-audit`'s `captureBefore` was the **only** writer of
9+
`ctx.__previous` in the repo, and #6656 retired it, so `buildContext`'s
10+
11+
```ts
12+
ctx.previous ?? (ctx as { __previous? }).__previous
13+
```
14+
15+
had a second operand nothing could ever bind. The engine is the single producer
16+
of the pre-image and it binds the declared key ahead of every dispatch — by-id
17+
update (`engine.ts:7010`, immediately before the `beforeUpdate` dispatch at
18+
`:7012`), by-id delete (`bindPreImage`, `engine.ts:7869`, called at `:7897`
19+
before the `beforeDelete` dispatch at `:7899`), and each per-row context of a
20+
predicate write (`engine.ts:1746` after-phase, `:1825` before-phase) —
21+
#5272 / #5574 / #5846.
22+
23+
Removed rather than kept "for safety", under ADR-0049 enforce-or-remove and
24+
PD #12: a fallback with zero producers is a second de-facto contract waiting to
25+
be rediscovered. The consequence is deliberate and stated here rather than left
26+
to be found — **a future producer of `ctx.__previous` is now silently ignored**;
27+
the declared way to hand this consumer a pre-image is `ctx.previous`.
28+
29+
The test that fed the limb synthesised `__previous` in its own body, which is
30+
what kept the dead limb looking live (#4984). It is replaced by the inverted pin
31+
the deletion actually needs — the same treatment the `doc` alias got in #5671
32+
so restoring the limb goes red instead of unnoticed.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/driver-memory": minor
4+
"@objectstack/driver-mongodb": minor
5+
"@objectstack/objectql": minor
6+
"@objectstack/formula": minor
7+
"@objectstack/service-analytics": minor
8+
---
9+
10+
feat(spec,drivers,objectql,analytics,formula): `$icontains` reaches every JS evaluation face (#6520)
11+
12+
The other half of #5702. That change implemented `$icontains` on the SQL family
13+
and correctly left the spec's `FILTER_OPERATORS` alone; this one adds the
14+
operator to that array and gives every remaining evaluation face an arm, in ONE
15+
change, because those two steps cannot be separated.
16+
17+
**Why one PR.** `FILTER_OPERATORS` is not a word list, it is a runtime allowlist:
18+
`driver-memory`'s shape gate derives from it, and its matcher's `default:` arm
19+
assumes the gate already refused anything unimplemented. Measured on a branch
20+
that added the name early (#5701): the gate stopped refusing, the matcher fell
21+
through, and `match({ name: 'zzz' }, { name: { $icontains: 'acme' } })` returned
22+
`true` — the predicate silently dropped, every row matched. A dropped predicate
23+
does not narrow a query, it WIDENS it, and on an RLS read scope that is a
24+
permission bypass rather than a degraded feature (#3948). So the word list
25+
travels with the evaluators or not at all.
26+
27+
**What now answers it**, all folding the same domain: `driver-memory` (query
28+
path, reference matcher, and the analytics/cube face), `driver-mongodb`,
29+
`objectql`'s `having`, `@objectstack/formula`'s `matchesFilterCondition` (the RLS
30+
write-side `check`), and `service-analytics`' three SQL compilers (the RLS
31+
lowering, the native-SQL strategy, and the `/analytics/sql` echo).
32+
33+
**The fold is ASCII-only, and that is the contract, not an implementation
34+
detail** (#4706 Q1 = A). `$icontains: 'café'` does not match `CAFÉ`. Every face
35+
reads one shared definition — `foldAsciiCase` /
36+
`asciiCaseInsensitiveContains` / `asciiCaseInsensitiveRegexSource`, new exports
37+
on `@objectstack/spec/data` — because the two obvious per-package spellings are
38+
both wrong in the same direction: `toLowerCase()` folds the whole Unicode range,
39+
and so does a `RegExp` built with the `i` flag. SQLite folds ASCII only and three
40+
of the five drivers are SQLite underneath, so a Unicode fold on a JS face would
41+
re-open exactly the divergence the ruling closed. The pattern-binding faces
42+
(mingo, mongo) therefore emit one `[Aa]` character class per ASCII letter and
43+
pass NO flags; mongo's `$icontains` is the one arm in its family that does not
44+
set `$options: 'i'`.
45+
46+
The comparand keeps the rules its SQL twin has: matched LITERALLY (`%`, `_` and
47+
regex metacharacters are ordinary characters), and refused when empty or
48+
non-string — an empty comparand matches every row, which is a predicate that
49+
constrains nothing.
50+
51+
**User-visible effect.** A filter using `$icontains` now behaves the same on the
52+
in-memory double and on SQL, so an app whose tests run on one and whose
53+
production runs the other stops getting two answers from one filter. Downstream,
54+
#5814 (better-auth `Where.mode: 'insensitive'`) no longer hits a 400 on the
55+
memory double.
56+
57+
Not changed, and still tracked: the `$contains` family still folds Unicode on
58+
`driver-memory`'s query path and `driver-mongodb` (#6682) — both remain DEBT rows
59+
in `scripts/check-driver-conformance.mjs`, now naming one open requirement each
60+
instead of two. `formula`'s unknown-operator posture stays a silent, fail-closed
61+
`false` (it governs a write-side check, where an unevaluable condition denies
62+
rather than widens); the decision and its limits are documented on
63+
`matches-filter.ts`, and no operator the spec DECLARES is answered that way any
64+
more.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): honour better-auth's `Where.mode`, and normalise the identifier SCIM matches on (#5814)
6+
7+
better-auth's `Where` carries a fourth field — `mode?: "sensitive" | "insensitive"`,
8+
`@default "sensitive"` — and `convertWhere()` in the ObjectQL adapter read `field` /
9+
`operator` / `value` and nothing else. The default covers almost every caller, so the
10+
drop was invisible; the caller it is not invisible for is the one that explicitly asked.
11+
12+
`@better-auth/scim` is that caller. SCIM's `userName` is case-insensitive by RFC 7643
13+
(`caseExact: false`), so a `filter=userName eq "Alice@example.com"` reaches this adapter
14+
as `{ field: 'email', operator: 'eq', mode: 'insensitive' }`. With `mode` unread, whether
15+
it matched a user stored as `alice@example.com` came down to how the driver under the
16+
auth path happens to compare strings — and because SCIM provisioning is "look up, create
17+
if absent", a missed match did not raise an error, it provisioned a **second user**.
18+
Only deployments that turned SCIM on (`OS_SCIM_ENABLED`, off by default) were exposed.
19+
20+
Both halves of the fix, per the maintainer's ruling on #5814:
21+
22+
- **Normalisation, not new vocabulary.** `sys_user.email` — the field SCIM's `userName`
23+
maps onto — is now stored lower-cased and compared lower-cased by this adapter. An
24+
insensitive lookup lower-cases its comparand, which is an *exact* match against the
25+
stored form, so nothing in the query vocabulary changes. The set is a declared table
26+
(`NORMALISED_IDENTIFIER_FIELDS`), not a name heuristic, and it drives the read and
27+
write halves from one place so a field cannot be added to one of them only.
28+
- **The silent drop ends.** `convertWhere()` handles `mode` explicitly. On a normalised
29+
identifier the request is satisfied by construction. On **any other** field, a
30+
`mode: 'insensitive'` clause now emits a loud warning naming the model, the field and
31+
the operator, and stating that the query is being answered case-sensitively — instead
32+
of answering a different question and looking fine doing it. It deliberately does not
33+
throw: refusing here would turn an occasional duplicate user into "`userName` queries
34+
entirely unavailable", which is the worse trade on an authentication path.
35+
36+
No migration ships and none is needed. Every existing write path already lower-cased
37+
`user.email` before reaching the adapter (better-auth's own `internalAdapter` does it on
38+
`createUser` / `createOAuthUser` / `updateUser` / `updateUserByEmail`, and SCIM's create
39+
path does it again), so the write half changes no existing behaviour — it moves the
40+
invariant the read half depends on into the layer that depends on it, instead of
41+
inheriting it from an internal of a prerelease dependency. Queries that do not set
42+
`mode`, or set it to `"sensitive"`, keep their comparand byte-for-byte: folding case
43+
unasked would be the same failure in the opposite direction.
44+
45+
Adding a case-insensitive equality operator (`$ieq`) was deferred until there is
46+
demonstrated pull for it, and downgrading `eq + insensitive` to `$icontains` was
47+
rejected — containment is not equality.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
"@objectstack/spec": patch
4+
"@objectstack/lint": patch
5+
---
6+
7+
fix(spec,lint): a virtual `formula` field in `searchableFields` is refused loudly, not admitted verbatim (#6674)
8+
9+
#4254 closed the fail-open on the unknown-name axis: a `$searchFields` entry the
10+
engine would not scan is `400 INVALID_FIELD`, never a silently widened search.
11+
The same shape survived one axis over, on names that are perfectly real.
12+
13+
The declared branch of `resolveSearchFieldResolution` filtered entries by
14+
EXISTENCE only, so a `formula` field declared in `searchableFields` entered the
15+
allowed set — and the ingress gate, which reads that same set, accepted it for
16+
exactly that reason. Measured on `origin/main`:
17+
18+
```
19+
AUTO: {"allowed":["name","project_name"],"source":"auto"} formula excluded
20+
DECL-FORMULA: {"allowed":["name","project_name_formula"],"source":"declared"} admitted verbatim
21+
?search=Apollo&searchFields=project_name_formula -> 200, 0 rows silent
22+
```
23+
24+
Zero rows is the defect. A formula value is computed on read and no driver
25+
materializes a column for it (`driver-sql` `fieldHasColumn`, driver-turso's
26+
"Virtual — no column"), so the `$contains` the engine expands `$search` into has
27+
nothing to scan: 0 rows on driver-memory (the property is absent from the stored
28+
row) and 0 rows WITH NO ERROR on driver-sql/better-sqlite3. The declaration read
29+
as search coverage and delivered none.
30+
31+
- **`@objectstack/spec` — the deciding face.** The declared branch now filters on
32+
existence AND scannability: an entry naming a virtual field is not admitted.
33+
New exports `SEARCH_VIRTUAL_TYPES` (exactly `formula`, pinned) and
34+
`isVirtualSearchField` — one judgment, so the resolution, the gate and the
35+
linter cannot drift about which types have a column. The resolution itself
36+
stays non-throwing: it is consulted on every search by internal callers that
37+
never pass an ingress, which is why #4254 put the loudness at the ingress.
38+
- **`@objectstack/metadata-protocol``400 INVALID_FIELD` with its own reason.**
39+
Split out before the declared/auto branch, because both of those messages are
40+
wrong for it: "outside the declared set" is false when the entry IS in the
41+
list, and the auto-default's "declare `searchableFields` to choose the
42+
searchable set" would instruct the author to write the declaration being
43+
refused. The new message names the field, its type, that the value is computed
44+
on read and never stored, and the fix (mirror onto a stored text field).
45+
- **`@objectstack/lint` — a build error at authoring time**, on the object's own
46+
`searchableFields` as well as a view's narrowing, under the existing
47+
`searchable-field-unsearchable` rule (no new rule id). This narrows the
48+
canonical surface, which #4830 had deliberately left existence-only.
49+
50+
The carve-out that made canonical existence-only is deliberately KEPT and pinned
51+
by controls in all three packages: the dividing line is STORAGE, not search
52+
quality. A `json` or `lookup` column declared in `searchableFields` is still the
53+
author's choice and still executed — a `$contains` over the stored JSON text or
54+
the stored foreign key. Narrow and rarely useful, but a scan that CAN match, so
55+
it is neither a 400 nor a finding. Only "there is no column at all" is refused.
56+
57+
**Compatibility.** A corpus sweep of this repo plus `objectui` and `cloud` found
58+
ZERO authored `searchableFields` naming a formula-typed field, so nothing in the
59+
tree changes verdict. For an already-published object that does carry one:
60+
loading is unaffected (no schema-parse change — `searchableFields` is still
61+
`z.array(z.string())`, this is a resolution and enforcement rule); a plain
62+
`?search=` keeps returning the SAME rows, because the dropped entry matched none
63+
of them; only a request that NAMES the formula field flips from `200` with no
64+
rows to `400 INVALID_FIELD` — including objectui's list search, which echoes the
65+
declaration verbatim. An object whose `searchableFields` is ENTIRELY formula
66+
entries filters to empty and falls through to the auto-default, exactly as an
67+
all-stale declaration has since #4254; the linter reports the declaration rather
68+
than leaving that swap silent.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
fix(metadata-protocol): refuse a sort naming a `formula` field instead of dropping it silently (#6994)
6+
7+
The list path's SORT gate (`assertSortFieldsExist`) refuses a sort naming a field
8+
the object does not have (#4226) and a dotted path that would have to cross into
9+
a related record (#4256). It did **not** refuse a name that is a real,
10+
non-dotted field of the object whose **type** materialises no column — a
11+
`formula` field is in the object's field map, so it passed the unknown check,
12+
and it carries no dot, so it passed the dotted check.
13+
14+
It then reached a driver that has no column for it. Re-measured on a real
15+
`SqlDriver` (better-sqlite3, on-disk) driving a real `ObjectQL` engine with this
16+
protocol on top, over five rows inserted `C A E B D` and a formula field
17+
`sort_key` whose expression is `record.title`:
18+
19+
```
20+
CONTROL orderBy title asc -> ["A","B","C","D","E"] a real column really sorts
21+
BASELINE no sort -> ["C","A","E","B","D"] insertion order
22+
23+
FORMULA orderBy sort_key asc -> ["C","A","E","B","D"] 5 rows, 200
24+
its sort_key values -> ["C","A","E","B","D"]
25+
FORMULA orderBy sort_key desc -> ["C","A","E","B","D"] byte-identical to asc
26+
27+
RAW SQL order by sort_key -> sqlite: no such column: sort_key
28+
```
29+
30+
`asc` and `desc` coming back identical is what makes this a dropped sort rather
31+
than a coincidence: `SqlDriver.createColumn` returns early for `formula` (it is
32+
virtual — computed on read, after `driver.find` has already returned), sqlite
33+
answers `no such column`, and the #3821 unknown-column backstop retries the
34+
query **without** the `ORDER BY`. The response even carries the values it was
35+
asked to order by, out of order, under a 200 — so it contradicts the request in
36+
plain view and still reports success. `sort` + `top` is how a caller asks for
37+
"the latest N", which this turned into an arbitrary N.
38+
39+
**Now:** `400 INVALID_SORT`, naming the field and its type, and prescribing the
40+
same remedy in the same words as the dotted refusal (#6924) and the SEARCH axis
41+
(#6673) — denormalise onto a **stored field, written when the source changes**.
42+
Precedence on this axis is `unknown` > `dotted` > unmaterializable, so both
43+
older verdicts answer exactly what they answered before.
44+
45+
**`summary` / `rollup` is not affected** and deliberately not in the refused
46+
set: a summary field gets a real, maintained `float` column and genuinely sorts.
47+
The spec's `COMPUTED_VALUE_TYPES` (`formula`/`summary`/`autonumber`) is the
48+
WRITE contract and is the wrong set to gate a sort with — it would refuse two
49+
types that work.
50+
51+
**Scope.** This is an ingress gate, so it covers what reaches `findData`: the
52+
REST list route, `POST /data/:object/query`, the export route, and the RPC
53+
dispatcher. An internal caller that reaches `engine.find()` directly (hooks,
54+
flows, reports) still gets the silent drop — closing that half means deciding
55+
whether the engine refuses or keeps its documented internal-caller tolerance,
56+
which is a separate contract decision and is tracked separately.
57+
58+
If you were sorting a list by a formula field, that sort was never applied; the
59+
call now fails loudly instead of returning rows in an arbitrary order.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): `WriteObservabilityOptions.strictReadonlyWrites` no longer claims INSERT ignores it (#7064)
6+
7+
The contract's closing paragraph still said "INSERT ignores it … insert is
8+
exempt from both strips, so there is nothing to refuse" — true when #5126
9+
shipped the option, false since #5503 wired `engine.insert` to REFUSE a
10+
payload carrying a runtime-owned value (`RUNTIME_OWNED_FIELD_TYPES`, today
11+
`autonumber`) under `strictReadonlyWrites: true`, throwing
12+
`ReadonlyFieldRejectedError` (`ERR_READONLY_FIELD_REJECTED`,
13+
`operation: 'insert'`) and writing nothing.
14+
15+
The TSDoc now states, measured against the engine: insert stays exempt from
16+
the two author-declared strips at this seam (#3413 — an in-process create may
17+
seed a `readonly: true` field's initial value; `readonlyWhen` cannot lock a
18+
create), while the runtime-owned strip runs on insert and is exactly what
19+
strict refuses; the exempt writers are the ones the error message names
20+
(`isSystem`, and `preserveAudit` for a #3493 historical import), explicitly
21+
scoped to this in-process seam so the DataProtocol ingress policy
22+
(#3043/#6640, `FieldSchema.readonly`) stays a distinct layer. Prose only — no
23+
key, type, or behaviour changes.

.github/workflows/lint.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,13 @@ jobs:
117117
- name: "@objectstack/verify stand-in erasure guard"
118118
run: pnpm check:verify-stand-in
119119

120-
# Raw control-byte guard (#3127 / #4890 / #5157 / #5460). Scans every
121-
# tracked TEXT file for a raw ASCII control byte and fails on any hit.
120+
# Raw control-byte guard (#3127 / #4890 / #5157 / #5460 / #6984). Scans
121+
# every TEXT file git knows about for a raw ASCII control byte and fails on
122+
# any hit. Since #6984 the scan set is the index PLUS untracked-but-not-
123+
# ignored working-tree files, so a locally-authored file is covered before
124+
# it is staged; here that widening is a no-op, because a workflow checks out
125+
# a commit and has no untracked files at all (the step's summary line says
126+
# so — it names both halves, and the untracked one reads 0 in CI).
122127
# WHICH bytes are in the set and WHY each is rejected are stated and argued
123128
# once, in the gate script's header — `scripts/check-nul-bytes.mjs`. That
124129
# header is authoritative and this comment cites it rather than restating it

0 commit comments

Comments
 (0)