Skip to content

Commit 03721a0

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20844-resolved-token-year-range
2 parents 574bcbb + 2a4254a commit 03721a0

35 files changed

Lines changed: 1158 additions & 88 deletions

‎.changeset/20887-analytics-nested-relation-engine-answer.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Clause-②: yes (narrowing)
1717
- At a measure's own `filter` the form is refused with `400 INVALID_FILTER`, as the engine refuses it at an aggregation's own `filter`: put the condition in the query's `where`.
1818
- `POST /api/v1/analytics/sql` refuses a `where` carrying the form with `400 INVALID_FILTER`: no statement it could print reproduces a read of the related object as the caller. The query itself is answered by `POST /api/v1/analytics/query`.
1919

20-
**Why.** Measured on the base over one fixture with the real security layer (a related field the caller may not read, a related row scope, 1,001 matching related records). The native-SQL strategy flattened the form to a dotted member and joined the related table: through a dataset that `include`d the relationship it answered rows for a condition on a field the caller cannot read, answered a match past the engine's cap, and counted a measure filter carrying the form; without the declared join it named a table that does not exist (500), and a multi-valued relation was refused. The engine-aggregate strategy refused the form as a cross-object filter (400). The engine serves the form since the nested-relation filter landed in `where`.
20+
**Why.** Measured on the base before the field-level gate (#20917) and the relationship-path admission (#20933) landed, over one fixture with the real security layer (a related field the caller may not read, a related row scope, 1,001 matching related records). The native-SQL strategy flattened the form to a dotted member and joined the related table: through a dataset that `include`d the relationship it answered rows for a condition on a field the caller cannot read, answered a match past the engine's cap, and counted a measure filter carrying the form; without the declared join it named a table that does not exist (500), and a multi-valued relation was refused. The engine-aggregate strategy refused the form as a cross-object filter (400). The engine serves the form since the nested-relation filter landed in `where`.
2121

2222
**How.** The native-SQL strategy declines a query in which the form appears in the `where`, the dataset's own `filter` or a requested measure's `filter`, so the query runs on the engine-aggregate path, which hands the form to the engine as written.
2323

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/driver-memory': minor
3+
'@objectstack/driver-mongodb': minor
4+
---
5+
6+
fix(driver-memory, driver-mongodb)!: a non-boolean `$exists` comparand is refused with `INVALID_FILTER` / 400, as `$null`'s is, instead of selecting the rows with no value (#20897)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (already-registered filter-query-face-comparands-refused-at-save) this narrows the query faces to the rule that registered entry already records: its reason states that every query face refuses a non-boolean $null / $exists flag, and its replacement is this change's whole migration (a flag is the boolean itself; $exists true is "has a value", false "has no value"). This change makes that statement true on the two drivers that did not yet refuse. No authorable key, spelling, export or published type moves, and no stored row is read or rewritten; a stored filter carrying such a flag is already refused when it is saved, by that entry. -->
11+
12+
**BREAKING**: this narrows what the in-memory driver and the MongoDB driver accept in a filter. A `$exists` comparand that is not a boolean (a string, a number, `null`, `undefined`, an object) is now refused with `INVALID_FILTER` / 400, where these two drivers used to answer it. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
13+
14+
`FieldOperatorsSchema` declares `$exists` as a boolean, and `driver-sql`, `driver-sqlite-wasm` and both Turso transports already refused any other comparand. The in-memory driver and the MongoDB driver did not: they read `$exists` as `value === true`, so every other value asked for the rows with NO value. `{ stage: { $exists: "yes" } }` and `{ stage: { $exists: 1 } }` returned the rows without a stage, the opposite of what was written. `0`, `null` and the string `"false"` landed on that same side by the same default, not because anything read them. The in-memory driver's analytics face read the same flag by truthiness and answered the valued rows for the same filter, so that driver gave two different answers.
15+
16+
**What an author sees now.** `400 INVALID_FILTER` with `driver-sql`'s message, beginning `Operator "$exists" on field "FIELD" requires a boolean comparand (true or false).` and naming the position (`filter.stage.$exists`). On the in-memory driver the refusal covers `find`, `findOne`, `count`, `aggregate`, `updateMany`, `deleteMany` and the analytics face (`query()` and `generateSql()`). There, an `undefined` or object comparand is refused first by that face's comparand-type check, also `INVALID_FILTER` / 400, in its own words. A refused write changes nothing.
17+
18+
**What to write instead.** Write the boolean itself. `"$exists": true` matches rows whose field has a value, and `"$exists": false` matches rows whose field has none.
19+
20+
**Who is affected.** A caller that sent a non-boolean `$exists` to `InMemoryDriver` or `MongoDBDriver` (a test suite, a local or embedded deployment, a flow or hook calling the engine in-process) and read the answer as a real one. On `SqlDriver` the same filter was already a 400.
21+
22+
**Unchanged.** `$exists: true` and `$exists: false` answer exactly as before. The aggregation `filter` and `having` positions, which the engine evaluates itself after the driver, are not changed by this entry.

‎.changeset/20917-analytics-field-permission-gate.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Clause-②: yes (narrowing)
88

99
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or stored shape is removed or renamed. The change refuses analytics queries that read a field the caller's field-level permissions hide, which the engine already refuses on the data API and on the ObjectQL strategy, so there is nothing for `objectstack migrate meta` to rewrite. The one public-surface addition is a new optional service hook. -->
1010

11-
**BREAKING for analytics queries on a SQL deployment that read a field the caller may not read.**
11+
**BREAKING for analytics queries that read a field the caller may not read: on a SQL deployment, and on `POST /api/v1/analytics/sql` whichever strategy serves the cube.**
1212

1313
**What changed.** `POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`
1414
and `POST /api/v1/analytics/dataset/query` now judge every field a query reads
@@ -19,7 +19,9 @@ filters. A member of an authored cube is judged by the field it resolves to,
1919
not by its name in the cube. A field the caller may not read answers
2020
`403 PERMISSION_DENIED`, in the words the engine uses for the same field. The
2121
native-SQL strategy, the one a SQL driver serves first, answered such queries;
22-
the ObjectQL strategy and the data API already refused them.
22+
the ObjectQL strategy already refused them on `POST /api/v1/analytics/query`
23+
and `POST /api/v1/analytics/dataset/query`, as the data API did, but printed
24+
the statement on `POST /api/v1/analytics/sql`.
2325

2426
**What is not affected.** A query that reads only fields the caller may read
2527
answers as before. A system context, and a caller with no permission sets, are

‎.changeset/20933-analytics-relationship-path-admission.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Clause-②: no (narrowing)
88

99
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or stored shape is removed or renamed. The change refuses, or row-scopes, what the native-SQL strategy read from an object reached through an undeclared relationship path, the way a declared join to the same object already was; there is nothing for `objectstack migrate meta` to rewrite. -->
1010

11-
**BREAKING for analytics queries on a SQL deployment that read a related object through a relationship path the cube does not declare.**
11+
**BREAKING for analytics queries that read a related object through a relationship path the cube does not declare: on a SQL deployment, and on `POST /api/v1/analytics/sql` whichever strategy serves the cube.**
1212

1313
**What changed.** The analytics door admits and row-scopes one object set
1414
before either strategy runs. It held the cube's base object and the joins the
@@ -40,7 +40,9 @@ deployment with no security service applies no object-level check, as on the
4040
data API.
4141

4242
**Refusals that change form.** On the ObjectQL strategy a related object the
43-
caller may not read was already refused; it now answers the analytics door's
43+
caller may not read was already refused on `POST /api/v1/analytics/query` and
44+
`POST /api/v1/analytics/dataset/query`, though `POST /api/v1/analytics/sql`
45+
printed the statement; on those two doors it now answers the analytics door's
4446
refusal rather than the engine's, the same one a declared join gets. A filter,
4547
a time window or a two-hop path through such an object moves from
4648
`400 INVALID_FIELD` to that `403`. A relationship path whose relationship name

‎.github/workflows/release.yml‎

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -740,7 +740,9 @@ jobs:
740740
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
741741

742742
# ══════════════════════════════════════════════════════════════════════════
743-
# PUSH LANE 2 — release integrity audit. Reads github.sha ONLY. Never mints.
743+
# PUSH LANE 2 — release integrity audit. Never mints. Reads github.sha and the
744+
# version commit it selects, both out of the object database; every backfill
745+
# builds from that version commit's tree, as the publish job does.
744746
# ══════════════════════════════════════════════════════════════════════════
745747
release-integrity:
746748
name: Release integrity (audit + no-mint backfill)
@@ -999,6 +1001,9 @@ jobs:
9991001
# has its repair — the `force` dispatch (D4) — not a push.
10001002
vc_tree="${RUNNER_TEMP}/release-version-commit"
10011003
git worktree add --quiet --detach "$vc_tree" "$version_commit"
1004+
# The backfill steps below build from this tree, not from this
1005+
# checkout (github.sha), so it is handed to them by name.
1006+
echo "version-tree=${vc_tree}" >> "$GITHUB_OUTPUT"
10021007
if ! group=$(RELEASE_VERSION="$version" node scripts/release-verify-npm.mjs --probe --root "$vc_tree"); then
10031008
echo "::error::could not read whether the whole fixed group of ${version} is on npm (reason above). Refusing to request a backfill nobody measured."
10041009
exit 1
@@ -1049,13 +1054,44 @@ jobs:
10491054
10501055
# Everything below is skipped on the overwhelmingly common path (nothing to
10511056
# repair), which is why the install is here rather than at the top of the job.
1057+
#
1058+
# ── Every backfill builds from the VERSION COMMIT's tree ─────────────
1059+
# The publish job checks out the version commit and runs that commit's
1060+
# own scripts over that commit's own tree (ADR-0125 D1 as amended
1061+
# 2026-09-29). A repair describes the same release, so the steps below
1062+
# run in the worktree the audit made of the version commit (its
1063+
# `version-tree` output), with that commit's lockfile and tooling, and
1064+
# never in this checkout. This checkout is `github.sha`, the head of
1065+
# whichever push is being audited. A landing after the version commit
1066+
# that touched packages/spec/src would otherwise make the D4 asset
1067+
# describe a tree npm did not ship, and a Release created here would
1068+
# point its CHANGELOG link, and any tag it has to create, at that landing.
1069+
#
1070+
# The checkout itself is NOT swapped: the audit above reads `github.sha`
1071+
# and its tripwire reads this workspace, and both stay as they are. Each
1072+
# step re-asserts that its tree is the version commit and refuses
1073+
# otherwise, because an empty `version-tree` would leave the step in
1074+
# this checkout, which is the defect, with nothing said.
1075+
#
1076+
# Pinned by battery 13 of `node scripts/release-verify-npm.mjs
1077+
# --self-test` (lint.yml runs it): these steps' own text runs after the
1078+
# audit's, and a landing that moved packages/spec/src is the fixture.
10521079
- name: Setup pnpm
10531080
if: steps.audit.outputs.releases-missing == 'true'
10541081
uses: ./.github/actions/setup-pnpm
10551082

1056-
- name: Install dependencies
1083+
- name: Install dependencies (the version commit's tree)
10571084
if: steps.audit.outputs.releases-missing == 'true'
1058-
run: pnpm install --frozen-lockfile
1085+
env:
1086+
VERSION_COMMIT: ${{ steps.audit.outputs.version-commit }}
1087+
VERSION_TREE: ${{ steps.audit.outputs.version-tree }}
1088+
run: |
1089+
if [ -z "$VERSION_TREE" ] || [ "$(git -C "$VERSION_TREE" rev-parse HEAD 2>/dev/null)" != "$VERSION_COMMIT" ]; then
1090+
echo "::error::the version commit's tree ('${VERSION_TREE}') is not at the version commit '${VERSION_COMMIT}'. Refusing to backfill from any other tree."
1091+
exit 1
1092+
fi
1093+
cd "$VERSION_TREE"
1094+
pnpm install --frozen-lockfile
10591095
10601096
- name: Backfill GitHub Releases (bodies truncated to the API limit)
10611097
if: steps.audit.outputs.releases-missing == 'true'
@@ -1066,22 +1102,46 @@ jobs:
10661102
# Changesets `fixed` group releases every public package at one
10671103
# version, which check-changeset-fixed.mjs gates.
10681104
RELEASE_VERSION: ${{ steps.audit.outputs.version }}
1069-
run: node scripts/release-github-releases.mjs
1105+
VERSION_COMMIT: ${{ steps.audit.outputs.version-commit }}
1106+
VERSION_TREE: ${{ steps.audit.outputs.version-tree }}
1107+
# The script reads every package and CHANGELOG.md from the tree it
1108+
# lives in, and takes `GITHUB_SHA` as each Release's `target_commitish`
1109+
# and as the ref of its CHANGELOG permalink. Both must be the version
1110+
# commit. GitHub ignores `target_commitish` when the tag already exists,
1111+
# but a publish that died before its tag push leaves no tag, and the
1112+
# Release then CREATES it at `target_commitish`. So the version
1113+
# commit's own script runs in its own tree, with GITHUB_SHA set for that
1114+
# one process only.
1115+
run: |
1116+
if [ -z "$VERSION_TREE" ] || [ "$(git -C "$VERSION_TREE" rev-parse HEAD 2>/dev/null)" != "$VERSION_COMMIT" ]; then
1117+
echo "::error::the version commit's tree ('${VERSION_TREE}') is not at the version commit '${VERSION_COMMIT}'. Refusing to backfill from any other tree."
1118+
exit 1
1119+
fi
1120+
cd "$VERSION_TREE"
1121+
GITHUB_SHA="$VERSION_COMMIT" node scripts/release-github-releases.mjs
10701122
10711123
- name: Backfill spec-changes.json on the GitHub Release (ADR-0087 D4)
10721124
# Ordering is load-bearing: `gh release upload` needs the Release the
10731125
# step above creates.
10741126
#
10751127
# `--prepare` regenerates the manifest against the previously published
1076-
# tarball exactly as the publish lane does, so the asset this repair
1077-
# uploads carries the same per-release section the npm artifact does.
1078-
# It cannot repair the npm artifact itself — that tarball is immutable —
1079-
# and this lane never publishes one.
1128+
# tarball exactly as the publish job does: in the version commit's
1129+
# tree, with that commit's generator. So the asset this repair uploads
1130+
# carries the same per-release section the npm artifact does. It cannot
1131+
# repair the npm artifact itself — that tarball is immutable — and this
1132+
# lane never publishes one.
10801133
if: steps.audit.outputs.releases-missing == 'true'
10811134
env:
10821135
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
10831136
RELEASE_VERSION: ${{ steps.audit.outputs.version }}
1137+
VERSION_COMMIT: ${{ steps.audit.outputs.version-commit }}
1138+
VERSION_TREE: ${{ steps.audit.outputs.version-tree }}
10841139
run: |
1140+
if [ -z "$VERSION_TREE" ] || [ "$(git -C "$VERSION_TREE" rev-parse HEAD 2>/dev/null)" != "$VERSION_COMMIT" ]; then
1141+
echo "::error::the version commit's tree ('${VERSION_TREE}') is not at the version commit '${VERSION_COMMIT}'. Refusing to backfill from any other tree."
1142+
exit 1
1143+
fi
1144+
cd "$VERSION_TREE"
10851145
bash scripts/release-spec-changes.sh --prepare
10861146
bash scripts/release-spec-changes.sh --attach
10871147

‎content/docs/deployment/cli.mdx‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -627,17 +627,36 @@ The docs step reports what it collected, not what it attempted — a project who
627627
successful collection prints.
628628
629629
Under an **ADR-0130 multi-package layout**, docs that moved into a package are
630-
collected too: a `src/<pkg>/docs/` directory whose `<pkg>` names one of the
630+
collected too: a `<pkg>/docs/` directory whose `<pkg>` names one of the
631631
artifact's declared `packages[]` entries is read into **that package's own
632632
body**, and the step line says so:
633633
`12 collected (4 from 2 package directories)`.
634+
The `<pkg>` directory is found from the packages the artifact registers, at
635+
**any depth** under `src/`: `src/<pkg>/docs/` and `src/packages/<pkg>/docs/`
636+
are one case, not two. The search descends through every directory whose name
637+
names none of the declared packages (never into `node_modules`) and **stops**
638+
at the first directory that names any of them. That directory is a package
639+
root and everything under it is that package's own source, so only its own
640+
`docs/` is read — a deeper `src/<pkg>/components/docs/` is neither read nor
641+
reported. A stack that declares no `packages[]` has nothing to search for: its
642+
docs are read from `src/docs/` alone, and a `src/<dir>/docs/` one level down
643+
is reported, not read.
634644
`<pkg>` is matched against two spellings of the package: its
635645
`id`, and the last dot-separated segment of that `id`. The display `name` is
636646
⛔ not a directory key — it is free to be re-worded, and a docs binding a
637-
re-wording can break is worse than one that never existed. A directory
638-
that matches **none** of the declared packages, or **more than one**, is still
639-
not read — that case keeps the warning that names the files it skipped and the
640-
spellings it tried, so it is never a silent `0 collected`.
647+
re-wording can break is worse than one that never existed.
648+
Docs the search finds but cannot attribute unambiguously are still not read:
649+
each such `docs/` directory is reported in a warning that names the files it
650+
skipped, so it is never a silent `0 collected`. Three cases lead there:
651+
652+
- a directory the search passes through, `src/packages/` itself included,
653+
whose name matches **none** of the declared packages — the warning lists the
654+
declared packages;
655+
- a directory whose name matches **more than one** declared package — the
656+
warning names those packages;
657+
- one package that answers to **more than one** directory with Markdown in its
658+
`docs/`, such as `src/core/docs/` beside `src/packages/core/docs/` — ⛔
659+
neither is read and nothing is merged, and each warning names the others.
641660
642661
The resulting `dist/objectstack.json` is a **portable, self-describing deployment unit** —
643662
you can hand it to `os start` (locally or on a server), publish it to a CDN, or fetch it

0 commit comments

Comments
 (0)