Skip to content

Commit 49de20f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14302-skills-query-optimization
2 parents 8208cf3 + a98b61b commit 49de20f

27 files changed

Lines changed: 1794 additions & 1445 deletions

.changeset/eighty-pumas-shave.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
Walk object-nested `list` / `listViews.*` through the view completeness rules.
6+
7+
`validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
8+
a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
9+
ADR-0017 "Object has-many View" spelling that `objects[].list` and
10+
`objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
11+
register the same expanded view items and reach the same renderer, so `os validate`
12+
and `os build` were silent on exactly the half of the stack the sibling rules
13+
(`lint-view-refs`, `validate-list-view-field-refs`) already walk.
14+
15+
Both authorable object spellings (array-form and name-keyed map) are covered, and a
16+
list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
17+
way it does on the top-level door — so `view/layout-without-binding` and
18+
`view/tree-without-parent-field` now reach the nested door by construction rather
19+
than by a second wiring step. Findings report as
20+
`object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): refuse a `null` comparand in the ordering positions — `$gt` / `$gte` / `$lt` / `$lte` (#14080)
6+
7+
**BREAKING** accept-set narrowing on the filter contract, shipped as `minor`
8+
under the repo's launch-window convention for breaking changes — the same
9+
convention, the same door and the same envelope as the 2026-08-31 refusal of
10+
`null` in the list-comparand positions (`$in` / `$nin` members, `$between`
11+
bounds). Maintainer ruling 2026-09-01 (option A): the four ordering positions
12+
were the last null-comparand positions the contract neither ruled on
13+
(`$eq: null` / `$ne: null` ARE the null predicate) nor refused, and
14+
`driver-memory`'s two faces answered them differently — the live path reads
15+
two absences as equal, so `$gte: null` admits the no-value row; the reference
16+
matcher compares through JS coercion, so `5 > null` is `5 > 0`. The contract
17+
now refuses the shape loudly at the validation entrance, so that divergence is
18+
constructively unreachable — ⛔ no ordering-vs-null semantics is defined
19+
anywhere, ⛔ the matcher is not repaired, ⛔ no cross-backend alignment.
20+
21+
What is refused, and where:
22+
23+
- **Runtime door** (`assertListComparandShapes`, run inside `parseFilterAST`
24+
and at the engine seam on every verb): `{ f: { $gt: null } }` and its three
25+
siblings, in the object form and in every array/authoring spelling that
26+
lowers to them (`>`, `gt`, `greater_than`, `after`, `before`, …), are refused
27+
with the platform envelope (`INVALID_FILTER` / 400). Previously the shape
28+
reached the backends unexamined.
29+
- **Schema door** (`ComparisonOperatorSchema` / `FieldOperatorsSchema`): `null`
30+
never parsed (the slot is `number | Date | string | { $field }`); it now gets
31+
the pointed message instead of zod's generic union text, and the two copies
32+
are built from one shared slot factory so they cannot drift.
33+
34+
The refusal text prescribes the ruled spellings: `{"$eq": null}` is "has no
35+
value", `{"$ne": null}` is "has a value". The carve-out is null-shaped and
36+
nothing wider: every number, `Date`, string (`''` included) and `{ $field }`
37+
comparand keeps parsing, `$eq: null` / `$ne: null` are untouched, and
38+
`undefined` keeps the comparand-TYPE door's own message.
39+
40+
**Migration.** A filter refused by the new check had no portable meaning to
41+
preserve — the two in-memory faces already disagreed on it. Spell the intent
42+
explicitly: `{ f: { $eq: null } }` for "has no value", `{ f: { $ne: null } }`
43+
for "has a value", and `$or: [{ f: { $gte: X } }, { f: { $eq: null } }]` for
44+
"at or above X OR has no value".
45+
46+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing over existing keys: no key is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. The refusal reaches an affected author at the parse/query site carrying the remedy; which explicit spelling matches the author's intent ($eq: null, $ne: null, or $or with one of them) is an authoring decision no migration entry can perform — and the ruling's precondition census measured zero authored occurrences of the refused shape. -->

.github/workflows/release.yml

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,9 +1091,16 @@ jobs:
10911091
# ci.yml's Console Pin Gate (#4290) uses this exact key, so the pin bump's
10921092
# PR run and this job share one build — keep the two in step if either
10931093
# input set changes.
1094-
- name: Cache vendored Console dist (keyed on the objectui pin)
1094+
#
1095+
# Split restore/save, matching ci.yml, and not the combined actions/cache
1096+
# action: the combined form's post-step saves even when the job FAILED,
1097+
# and build-console.sh writes the SHA stamp BEFORE it asserts the bundle
1098+
# canary — so a canary failure here would seed this shared, repo-scoped
1099+
# key with a stamped-but-broken dist that later runs restore and sail
1100+
# through. See ci.yml's split-restore note for the full rationale.
1101+
- name: Restore vendored Console dist (keyed on the objectui pin)
10951102
id: console-dist-cache
1096-
uses: actions/cache@v6
1103+
uses: actions/cache/restore@v6
10971104
with:
10981105
path: packages/console/dist
10991106
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
@@ -1109,6 +1116,31 @@ jobs:
11091116
- name: Verify Console dist stamp matches pin
11101117
run: pnpm check:console-sha
11111118

1119+
# The spec-injection half of the same question, spelled exactly as
1120+
# ci.yml's Console Pin Gate spells it — both consumers of one cache key
1121+
# apply one check. check:console-sha answers only "which objectui SHA",
1122+
# and scripts/assert-console-spec-injection.mjs runs INSIDE
1123+
# build-console.sh, which the `if: cache-hit != 'true'` above skips — so
1124+
# on a cache hit nothing had asked whether the dist this job is about to
1125+
# PUBLISH bundles this tree's @objectstack/spec rather than the published
1126+
# tarball. --require-stamp because the vacuity check:console-sha tolerates
1127+
# (no dist, or a dist no build ever stamped) is wrong on a publish lane.
1128+
- name: Verify the restored Console dist bundles this tree's spec
1129+
env:
1130+
CONSOLE_DIST_CACHE_KEY: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
1131+
run: pnpm check:console-injection --require-stamp
1132+
1133+
# Reached only with every console assertion above green: Actions steps
1134+
# default to `success()`, and nothing between the restore and here carries
1135+
# `if: always()` or `continue-on-error`. Saving only on a miss keeps an
1136+
# immutable entry from being rewritten on every release run.
1137+
- name: Save vendored Console dist
1138+
if: steps.console-dist-cache.outputs.cache-hit != 'true'
1139+
uses: actions/cache/save@v6
1140+
with:
1141+
path: packages/console/dist
1142+
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
1143+
11121144
- name: Downstream backward-compat smoke (live hotcrm)
11131145
# Pre-publish gate (#2035): the about-to-publish @objectstack/spec must
11141146
# not break a real third-party consumer pinned to a published release.

content/docs/ai/skills-reference.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ ObjectStack ships **11 skills** — one per authoring domain, plus process skill
4444
| # | Skill | Domain | Path | What it covers |
4545
| :--- | :--- | :--- | :--- | :--- |
4646
| 1 | [Platform](#platform) | `platform` | `skills/objectstack-platform/` | Bootstrap, configure, extend, and operate ObjectStack runtimes. Covers project setup (`defineStack`, drivers, adapters, scaffolding), plugin and service development (PluginContext, DI, kernel hooks like `kernel:ready`), and operations (CLI commands, migrations, deployment, test harnesses via LiteKernel). |
47-
| 2 | [Data](#data) | `data` | `skills/objectstack-data/` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them. |
47+
| 2 | [Data](#data) | `data` | `skills/objectstack-data/` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security, data `lifecycle` retention/TTL/rotation, metadata `protection` locks, and external / federated datasources (`defineDatasource`) — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them. |
4848
| 3 | [Query](#query) | `query` | `skills/objectstack-query/` | Construct ObjectQL queries — filters, sorting, pagination, aggregation, relation expansion, and full-text search. |
4949
| 4 | [UI](#ui) | `ui` | `skills/objectstack-ui/` | Author ObjectStack UI metadata — Views (list/form/kanban/calendar/gantt), Apps (navigation), Pages (structured plus the HTML and React source-authoring tiers, ADR-0080/0081), Dashboards, Reports, Charts, Actions, and package Docs (`src/docs/*.md`). |
5050
| 5 | [Automation](#automation) | `automation` | `skills/objectstack-automation/` | Design ObjectStack automation — Flows (visual logic), Triggers, Approvals, state machines, scheduled jobs, and webhooks. |
@@ -75,9 +75,9 @@ Do not use for data schema design (see objectstack-data) or query patterns (see
7575

7676
**Domain** `data` · **Path** `skills/objectstack-data/`
7777

78-
Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them.
78+
Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security, data `lifecycle` retention/TTL/rotation, metadata `protection` locks, and external / federated datasources (`defineDatasource`) — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them.
7979

80-
Use when the user is creating or modifying `*.object.ts` / `*.seed.ts` files, picking field types, modelling relationships, writing `beforeInsert`/`afterUpdate` hooks, configuring per-object access control, or authoring bootstrap / demo data. Use for `visibleWhen` / `readonlyWhen` / `requiredWhen` rules that belong on fields.
80+
Use when the user is creating or modifying `*.object.ts` files or `src/data/*.ts` seed modules, picking field types, modelling relationships, writing `beforeInsert`/`afterUpdate` hooks, configuring per-object access control, pointing an object at an existing external database, or authoring bootstrap / demo data. Use for `visibleWhen` / `readonlyWhen` / `requiredWhen` rules that belong on fields.
8181

8282
Do not use for querying data (see objectstack-query) or for plugin / kernel hooks (see objectstack-platform). CEL expressions in formulas / validations / sharing rules / dynamic seed values: load objectstack-formula alongside.
8383

content/docs/permissions/authentication.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,9 @@ console.log('Current user:', session.data.user);
247247

248248
### First-run bootstrap status
249249

250-
`GET /api/v1/auth/bootstrap-status` answers one question — does this environment
251-
have any user yet?
250+
`GET /api/v1/auth/bootstrap-status` answers one question — is the admission gate's
251+
bootstrap window open, i.e. does this environment have no **human** user yet? Service
252+
rows (such as the legacy `usr_system` row) do not count.
252253

253254
```typescript
254255
const res = await fetch('http://localhost:3000/api/v1/auth/bootstrap-status');
@@ -1072,7 +1073,7 @@ All endpoints are available under `/api/v1/auth/*`:
10721073
#### Session
10731074

10741075
- `GET /api/v1/auth/get-session` - Get current user session
1075-
- `GET /api/v1/auth/bootstrap-status` - Public, unauthenticated first-run probe: `{ "hasOwner": boolean }`, telling a client whether this environment has any user yet ([details](#first-run-bootstrap-status))
1076+
- `GET /api/v1/auth/bootstrap-status` - Public, unauthenticated first-run probe: `{ "hasOwner": boolean }`, telling a client whether the admission gate's bootstrap window is still open — i.e. whether this environment has no human user yet ([details](#first-run-bootstrap-status))
10761077

10771078
#### Password Management
10781079

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#14080] Ruling point 4's NEGATIVE pin, matcher side: a refused null
5+
* ORDERING comparand cannot reach this package's reference matcher.
6+
*
7+
* # What was ruled (2026-09-01, option A)
8+
*
9+
* #14080 measured, on this package's two faces and the card's numeric
10+
* fixture, that `{n: {$gt: null}}` / `{$gte: null}` / `{$lte: null}` answer
11+
* DIFFERENTLY: the live (mingo) path reads two absences as EQUAL, so
12+
* `$gte: null` admits the no-value row and `$gt: null` does not, while the
13+
* reference matcher compares through JS coercion, so `5 > null` is `5 > 0`.
14+
* It was the last null-comparand position the contract neither ruled on
15+
* (`$eq: null` / `$ne: null` ARE the null predicate, #5332) nor refused (the
16+
* 2026-08-31 ruling refused the `$in` / `$nin` members and the `$between`
17+
* bounds, #13357). The ruling REFUSES the shape at the contract's validation
18+
* entrance (`@objectstack/spec`, `assertListComparandShapes`, run inside
19+
* `parseFilterAST` and at the engine seam) instead of defining the semantics:
20+
* the divergence becomes constructively unreachable, ⛔ deliberately not
21+
* repaired (「⛔ 不单独修 matcher(死代码)」) and ⛔ no ordering-vs-null rule is
22+
* stated anywhere (「B(定义语义)排除」), so NOTHING in this file asserts what
23+
* either face would have answered. `memory-matcher-null-value-and-comparand.test.ts`
24+
* keeps those cells deliberately absent for the same reason.
25+
*
26+
* # What this file pins, and its honest boundary
27+
*
28+
* The same pipeline and the same boundary as
29+
* `memory-null-list-member-unreachable.test.ts`: a direct caller of this
30+
* driver compiles its filter with `parseFilterAST` and hands the result over,
31+
* and this file drives that pipeline end to end, pinning that for every
32+
* refused shape it ABORTS at the compile face, on BOTH readings of "no value",
33+
* before any row is consulted. The engine half (every verb, driver-call
34+
* witness) is pinned in `@objectstack/objectql`'s
35+
* `engine-filter-array-lowering.test.ts`; the wire/protocol face runs the same
36+
* `parseFilterAST`. `match()` and `InMemoryDriver.find()` remain plain library
37+
* functions — a caller that skips the compile face meets only this package's
38+
* own `assertFilterConditionShape`, which is deliberately NOT extended to the
39+
* null-ordering rule (⛔ 不做跨后端对齐工程). Same boundary as every #5869
40+
* refusal since #9228; not widened here.
41+
*/
42+
43+
import { describe, it, expect } from 'vitest';
44+
import { parseFilterAST } from '@objectstack/spec/data';
45+
46+
import { match } from './memory-matcher.js';
47+
48+
type Refusal = Error & { code?: string; status?: number };
49+
50+
/**
51+
* The card's own NUMERIC fixture, in both readings of "no value" — numeric
52+
* because `null` coerces to `0` under a relational comparison, which is the
53+
* coercion that split the two faces; a string fixture hides it (#13553).
54+
*/
55+
const NULLED_ROWS: Array<Record<string, unknown>> = [
56+
{ id: '1', n: 5 },
57+
{ id: '2', n: 0 },
58+
{ id: '3', n: null },
59+
];
60+
const MISSING_ROWS: Array<Record<string, unknown>> = [
61+
{ id: '1', n: 5 },
62+
{ id: '2', n: 0 },
63+
{ id: '4' },
64+
];
65+
66+
/**
67+
* The direct-caller pipeline: compile first, evaluate second. The refusal has
68+
* to land in step one — if compile returns, the matcher HAS been reached and
69+
* the pin below fails on the sentinel rather than on a missing throw.
70+
*/
71+
function compileThenMatch(rows: Array<Record<string, unknown>>, where: unknown): string[] {
72+
const condition = parseFilterAST(where);
73+
return rows.filter((row) => match(row, condition)).map((row) => String(row.id));
74+
}
75+
76+
const refusalOf = (run: () => unknown): Refusal => {
77+
try {
78+
run();
79+
} catch (e) {
80+
return e as Refusal;
81+
}
82+
throw new Error('expected the compile face to refuse this filter, but it returned');
83+
};
84+
85+
describe('[#14080] a refused null ordering comparand cannot reach the matcher (ruled 2026-09-01)', () => {
86+
it.each([
87+
['$gt: null', { n: { $gt: null } }],
88+
['$gte: null', { n: { $gte: null } }],
89+
['$lt: null', { n: { $lt: null } }],
90+
['$lte: null', { n: { $lte: null } }],
91+
['lowered array form, ">="', [['n', '>=', null]]],
92+
['lowered array form, "before"', [['n', 'before', null]]],
93+
])('%s aborts at the compile face on BOTH readings of "no value"', (_label, where) => {
94+
// Record-independent by construction — the compile face never sees a row —
95+
// so the two readings that split the faces (the card's table) cannot even
96+
// be posed. Driving both anyway is the point of the pin: neither fixture
97+
// gets an answer, so there is no divergence left to observe.
98+
for (const rows of [NULLED_ROWS, MISSING_ROWS]) {
99+
const err = refusalOf(() => compileThenMatch(rows, where));
100+
expect(err.code, _label).toBe('INVALID_FILTER');
101+
expect(err.status, _label).toBe(400);
102+
}
103+
});
104+
105+
it('the pipeline itself is real — a legal ordering comparand compiles and the matcher answers', () => {
106+
// Positive control: without it, the refusals above would also "pass" if
107+
// compileThenMatch were broken outright. `0` is the discriminator the
108+
// numeric fixture exists for — a VALUE, kept in, on every arm.
109+
expect(compileThenMatch(NULLED_ROWS, { n: { $gt: 0 } })).toEqual(['1']);
110+
expect(compileThenMatch(NULLED_ROWS, { n: { $gte: 0 } })).toEqual(['1', '2']);
111+
expect(compileThenMatch(MISSING_ROWS, { n: { $lt: 5 } })).toEqual(['2']);
112+
expect(compileThenMatch(MISSING_ROWS, [['n', '<=', 0]])).toEqual(['2']);
113+
});
114+
115+
it('the null PREDICATE still passes the same face — the refusal is ordering-shaped, not null-shaped', () => {
116+
// `$eq: null` IS the null predicate on both readings (#13494) and is the
117+
// spelling the refusal prescribes; the carve-out must not catch it.
118+
expect(compileThenMatch(NULLED_ROWS, { n: { $eq: null } })).toEqual(['3']);
119+
expect(compileThenMatch(MISSING_ROWS, { n: { $eq: null } })).toEqual(['4']);
120+
expect(compileThenMatch(NULLED_ROWS, { n: { $ne: null } })).toEqual(['1', '2']);
121+
});
122+
});

0 commit comments

Comments
 (0)