Skip to content

Commit 2926ee9

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21589-detail-sortfield-retire
2 parents 810d962 + e367002 commit 2926ee9

20 files changed

Lines changed: 985 additions & 184 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/driver-mongodb': patch
3+
---
4+
5+
Provenance comments in `@objectstack/driver-mongodb` cite the commits that decided them, not tracker numbers that no longer resolve
6+
7+
Clause-②: no
8+
9+
Docblocks and comments across the package cited issue-tracker numbers that now answer 404 on GitHub.
10+
Each one now cites the commit in this repository's history that made the decision it describes. One of
11+
these docblocks sits on an exported member (`MongoDBDriver.update()`), so the reworded text appears in
12+
the published `index.d.ts` / `index.d.mts`; that docblock and one more comment esbuild keeps appear in
13+
the JavaScript output (`index.js` / `index.mjs`); the sourcemaps do not change.
14+
15+
Comment only: no export, type, error code, status, message text or runtime behaviour changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(metadata-protocol)!: the generic data door refuses a stored-metadata filter that reads the body or a content hash through a cross-field comparand or below its depth backstop, and exports its one filter-field collector and one search narrowing for the reader-context seam (#21544)
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) no metadata body, authorable key, spelling, export or stored shape moves; what changes is which read-query shapes the generic data door accepts over the two stored-metadata tables, and two module functions are added to the package surface, so `objectstack migrate meta` has nothing to rewrite. The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers a refused query shape (not `registered` / `already-registered`); and the change is runtime behaviour plus additive exports, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
11+
12+
**BREAKING**: this narrows what the generic data door (`GET /api/v1/data/:object`, `POST /api/v1/data/:object/query` and the in-process `findData`) accepts when it reads `sys_metadata` or `sys_metadata_history`. Two filter shapes read the stored body column or a content-hash column (`checksum`, `previous_checksum`, or the history table's `change_note`) without the family's refusal ever seeing them, and both ran before this release:
13+
14+
- a cross-field comparand naming one of those columns — `{ "name": { "$ne": { "$field": "metadata" } } }`, in `where` or in an aggregation's `filter`, under `$not` included. The SQL drivers evaluate it row by row, so row presence disclosed the column's value;
15+
- a filter on one of those columns nested more than 32 combinators deep, which the door's field collector stopped reading at. A body `$contains` of a stored credential answered the row and a wrong guess answered none.
16+
17+
Both now answer the door's `400 INVALID_FIELD`, naming the column, before the query runs — the answer the same filter already gets when it names the column directly. The route: filter those tables by their scalar columns (the type, the name, the state and the like), compare scalar columns with each other, and read the bodies with a plain list, which is served projected. Every other column of the two tables, and every other object, is unchanged; a dotted key into one of those columns was, and stays, refused by the door's dotted-path rule. It ships as `minor` under the launch-window convention for accept-set narrowings.
18+
19+
- **`@objectstack/metadata-protocol`** exports two module functions the generic data door now calls itself:
20+
- `collectStoredMetadataFilterFields(object, query)` — the family's one filter-field collector: every column a read query's filters read (`where`, the engine's `filter` alias and each aggregation filter): each key's head and each cross-field `{ $field }` comparand, at any depth. `[]` outside the family.
21+
- `narrowStoredMetadataSearch(object, query, schema, wireSpelling?)` — the family's one default-search narrowing: an explicit search-field list naming the body or a hash column is refused, a default search is narrowed to the searchable set without them (returned for the caller to run as `searchFields`), and a set that narrows to nothing is refused. The `StoredMetadataSearchSchema` type it reads is exported beside it.
22+
- **`@objectstack/runtime`**: the stored-metadata reader-context seam (`ctx.api.object(...)` for action and hook bodies, a handler's `ctx.api`, and `ctx.engine.find`) calls those two functions instead of its own copy of the narrowing and `@objectstack/plugin-security`'s condition walk, so the seam and the door answer every family filter and search identically. A `count` through the seam now runs the query the guard returns. The seam's accept set is unchanged: every shape it refused before it still refuses, now through the door's collector.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
The runtime save door refuses a view container saved under a name its own expansion produces
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at one runtime write door over existing keys: no key of `ViewSchema` or of any other metadata schema is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. Whether such a container was meant as the object's container or as a view item of that name is authoring intent no conversion entry can decide. New saves are refused with the remedy; a row stored before this change keeps its bytes and is served as before, and no stored row is re-saved. The census found no such row and no writer that produces the shape by default: no seeded `sys_metadata` view rows in the example apps, no packaged container with a top-level `name` among the twelve `defineView` sites in `examples/`, and no Studio or in-repo AI writer that saves a container under an expanded name unless its author types that name into the container (Studio's generic metadata editor saves a body under its own `name`); hosted tenants were not measured. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered); and the change narrows what a runtime write door accepts, not a runtime interface or a type surface alone (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** accept-set narrowing at the runtime save door, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the same door's `name` refusals shipped with.
12+
13+
**What was accepted before.** `saveMetaItem`, which `PUT /api/v1/meta/view/:name` and the dispatcher's metadata save both call, accepted an aggregated view container (`list` / `form` / `listViews` / `formViews`) saved under one of the names its own expansion produces: for example `{ name: 'crm_lead.default', object: 'crm_lead', list: { … } }` saved as `crm_lead.default`, the name its bare `list` expands to. That row is the name's own stored row, and an expansion fills only names that have no row of their own (the object door adopts that rule in this same release), so the container's expansion never filled it. The object door (`GET /api/v1/meta/view?object=…`), which never lists a container, listed nothing under the name, and the by-name read answered the raw container. No door answered a view item for the name, and nothing said why.
14+
15+
**What is refused now.** That save, with `VALIDATION_ERROR` / 400, before anything is stored or registered, in draft and in publish mode. Whether a name is one the container's own expansion produces is decided by the same expansion the read doors run, so every member kind (a bare or named `list`, `listViews`, `form`, `formViews`) and the expander's de-duplicated names (`…_2`) are judged where the readers place them. A container with no `name` is judged under the save name the door stamps on it. A container on another package's object expands under its own name, which is never the name it is saved under, so it is not refused.
16+
17+
**What still saves.** A container under its object's name, which expands as before. A view item (a body carrying `viewKind`) under an expanded name, the sanctioned override for that name. The read doors are unchanged. A row stored in this shape before this change keeps its bytes and is served as before; `migrate meta --stored` and package duplication, which re-save stored rows through this door, now report such a row as failed with this refusal instead of re-saving it.
18+
19+
**The fix.** Save the container under its object's name (`crm_lead`), or save a view item (`name`, `object`, `viewKind`, `config`) under the expanded name (`crm_lead.default`).

‎packages/drivers/driver-mongodb/src/mongodb-11151-boolean-aggregand-answers.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
* ## The ruling this suite pins
88
*
99
* - **`sum` / `avg` answer arithmetic** — `3` / `0.5` over a 3-true/3-false
10-
* fixture. The #11065 family shape, landed on `driver-memory` and on every
10+
* fixture. The commit 20950404c family shape, landed on `driver-memory` and on every
1111
* SQL dialect (#11635).
1212
* - **`min` / `max` answer `0` / `1`** — #11152 (maintainer 2026-08-28,
13-
* applied on that card's comment 5448627494, ruling verbatim and
13+
* landed as commit f6fa22ce1, ruling verbatim and
1414
* untranslated: 「12745 A回,其他同意。」), SUPERSEDING #11249's
1515
* `false` / `true`: booleans aggregate as NUMBERS on every face, with no
1616
* per-aggregate exception, so one boolean column's aggregates answer in one

‎packages/drivers/driver-mongodb/src/mongodb-aggregation.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -646,15 +646,15 @@ export function buildAggregationPipeline(opts: {
646646
* `$sum`'s identity `0` and averaged to `null` here, while `SUM(col)` /
647647
* `AVG(col)` answer `3` / `0.5` over the same 3-true/3-false rows on every SQL
648648
* dialect (#11635), `driver-memory` answers those numbers on both of its faces
649-
* (#11065), and objectql's in-memory fallback answers them too because its
649+
* (commit 20950404c), and objectql's in-memory fallback answers them too because its
650650
* `toNumber` is `Number(v)` and `Number(true) === 1`. A rate measure over a
651651
* flag column — an SLA-violation rate, a win rate — is the ordinary shape of
652652
* that query, and the two answers are not two spellings of one: a dashboard
653653
* tile bound to the measure renders a percentage under SQL and a blank here,
654654
* indistinguishable from "no matching rows". `sum`'s `0` is the worse half,
655655
* being a plausible number rather than a visible hole.
656656
*
657-
* The expression is the one #11065 landed on `driver-memory`'s analytics face
657+
* The expression is the one commit 20950404c landed on `driver-memory`'s analytics face
658658
* (`memory-analytics.ts`, `numericAggregandExpr`), reproduced rather than
659659
* imported: this driver shares no line of code with that one, and the shared
660660
* contract between them is the VALUES in `@objectstack/spec/data`, not a

‎packages/drivers/driver-mongodb/src/mongodb-driver.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
155155
it('should update a record and return updated data', async () => {
156156
await driver.create('task', { id: 'upd-1', title: 'Original', status: 'new' });
157157
const result = await driver.update('task', 'upd-1', { title: 'Updated', status: 'done' });
158-
// `update()` declares `Record<string, unknown> | null` (#14428): a miss
158+
// `update()` declares `Record<string, unknown> | null` (commit ca3fd4b1a): a miss
159159
// answers `null`. This case is the FOUND arm, so pin that first and read
160160
// the fields through it -- same idiom as `findOne` above.
161161
expect(result).not.toBeNull();

‎packages/drivers/driver-mongodb/src/mongodb-driver.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ export class MongoDBDriver implements IDataDriver {
429429
}
430430

431431
/**
432-
* [#14428] A miss answers `null` — the arm `IDataDriver.update()` declares
432+
* [commit ca3fd4b1a] A miss answers `null` — the arm `IDataDriver.update()` declares
433433
* (#13878) and the one `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and
434434
* `TursoDriver`'s local face already return.
435435
*

‎packages/drivers/driver-mongodb/src/mongodb-exists-has-value-translation.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* [#13195] What `translateFilter` emits for `$exists`, and what MongoDB makes
4+
* [commit 9dac1ae01] What `translateFilter` emits for `$exists`, and what MongoDB makes
55
* of it on a row with NO VALUE — measured, not read.
66
*
77
* ## The ruling, and why this driver was thought to be the hard half
@@ -93,7 +93,7 @@ function matchMongoDoc(row: Record<string, unknown>, doc: Record<string, unknown
9393
if ((cond as Array<Record<string, unknown>>).some((b) => matchMongoDoc(row, b))) return false;
9494
continue;
9595
}
96-
// [#13195] Modelled because the emitter now produces it: a lowered
96+
// [commit 9dac1ae01] Modelled because the emitter now produces it: a lowered
9797
// `$exists` whose key is already taken by a sibling operator is promoted to
9898
// its own branch rather than merged over the sibling.
9999
if (field === '$and') {
@@ -203,7 +203,7 @@ describe('[#13195] `$exists` translation and its answer on a no-value row', () =
203203
});
204204

205205
/**
206-
* [#13195] `$exists` SHARING a field constraint with another operator.
206+
* [commit 9dac1ae01] `$exists` SHARING a field constraint with another operator.
207207
*
208208
* Not a cell the card or the ruling names — it is a consequence of the
209209
* prescribed lowering, found by measuring it. `{$ne: null}` / `{$eq: null}`

‎packages/drivers/driver-mongodb/src/mongodb-filter.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ describe('MongoDB Filter Translator', () => {
228228
});
229229

230230
it('translates $exists to the nullness test — has-value, not key-presence', () => {
231-
// [#13195, ruled 2026-08-30] Was `{avatar: {$exists: true}}`, a
231+
// [commit 9dac1ae01, ruled 2026-08-30] Was `{avatar: {$exists: true}}`, a
232232
// passthrough, which is key-presence at the wire level. `$exists` means
233233
// HAS A VALUE (`!= null`) — #5298 leg 3 / #5369 — and the lowering is the
234234
// one the `$null` arm in the same file already emits.

‎packages/drivers/driver-mongodb/src/mongodb-filter.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,7 +1037,7 @@ function translateCondition(
10371037
// sibling operator on the same field. Merging one would drop a
10381038
// constraint silently, so each becomes its own `$and` branch — see
10391039
// `assembleLoweredWrites()`. This consumed a single `_presenceAnd`
1040-
// when the guard covered `$exists` alone (#13195); it is a LIST now
1040+
// when the guard covered `$exists` alone (commit 9dac1ae01); it is a LIST now
10411041
// because the class has several members and one field constraint
10421042
// can contest more than one key.
10431043
const extraAnd = translated._extraAnd as Record<string, unknown>[] | undefined;
@@ -1077,7 +1077,7 @@ function translateCondition(
10771077
* Read straight off the spec's `FILTER_OPERATORS` declaration order rather than
10781078
* hand-copied, so a seventeenth operator is ranked the day it is declared. The
10791079
* rank of `$exists` (last in that list) is what makes this generalisation emit,
1080-
* byte for byte, the documents #13195's guard already emits for the one
1080+
* byte for byte, the documents commit 9dac1ae01's guard already emits for the one
10811081
* operator it moved. `$like` / `$ilike` are declared but NOT translated by this
10821082
* driver — the `default:` arm refuses them before the assembly runs — so the
10831083
* fallback below is a totality floor, never a live path.
@@ -1146,7 +1146,7 @@ interface LoweredWrite {
11461146
*
11471147
* Free key → merge inline (the overwhelmingly common case). Taken key → the
11481148
* write becomes its own `$and` branch on the same field, where both constraints
1149-
* survive. That is exactly the guard #13195 landed for `$exists` alone,
1149+
* survive. That is exactly the guard commit 9dac1ae01 landed for `$exists` alone,
11501150
* generalised to every writer rather than restated once per operator.
11511151
* `driver-memory`'s reference matcher looped the operators and therefore could
11521152
* not express this defect at all; it was the oracle both drivers agreed with
@@ -1239,7 +1239,7 @@ function translateFieldOperators(
12391239
* {@link assembleLoweredWrites} after the loop. Collected rather than
12401240
* assigned because an arm cannot know whether the key it wants is already
12411241
* spoken for by a sibling operator the author wrote LATER — which is the
1242-
* whole of the defect this replaces. It subsumes #13195's single-operator
1242+
* whole of the defect this replaces. It subsumes commit 9dac1ae01's single-operator
12431243
* `presence` collection: `$exists` is one writer among the rest now.
12441244
*/
12451245
const writes: LoweredWrite[] = [];
@@ -1262,7 +1262,7 @@ function translateFieldOperators(
12621262
put(op, store(value));
12631263
break;
12641264

1265-
// [#13195] Value-independent — a presence predicate takes a boolean, not
1265+
// [commit 9dac1ae01] Value-independent — a presence predicate takes a boolean, not
12661266
// a comparand, so it is never coerced. And "present" means the field HAS
12671267
// A VALUE (`!= null`), never key presence: #5298 leg 3 / #5369, landed in
12681268
// PR #5962, ruled onto this driver by the maintainer on 2026-08-30.
@@ -1473,7 +1473,7 @@ function translateFieldOperators(
14731473

14741474
// [#13524] Assemble every lowered write, and do NOT let one clobber another.
14751475
//
1476-
// #13195 landed this rule for `$exists` alone and said in this spot that the
1476+
// Commit 9dac1ae01 landed this rule for `$exists` alone and said in this spot that the
14771477
// identical clobber was reachable through `$null` and `$between`. Enumerating
14781478
// the declared vocabulary instead of the noticed operators found the whole
14791479
// `$regex` string family too, which `driver-memory` had promoted for years

0 commit comments

Comments
 (0)