Skip to content

Commit c955c56

Browse files
committed
Merge origin/main into claude/exported-types-any-resolution-1x1erg
Two conflicts, both in code #4165 ("reject unknown keys across the app shell and navigation tree") rewrote underneath this branch: - `packages/spec/src/ui/app.zod.ts` — main turned the nav union into a `z.discriminatedUnion('type', …)` with `.strict()` members and kept the `z.ZodType<any>` annotation; this branch replaced that annotation with the real `NavigationItem`. Both are kept. Reconciling them needed one addition: main widens the member array (`as unknown as readonly [ZodObject<ZodRawShape>, …]`) because the members are lazySchema Proxies and a superRefine-wrapped variant, and `discriminatedUnion` then reports the union's output as `Record<string, unknown>` — which fits `z.ZodType<any>` and nothing sharper. The union therefore takes a matching cast back to `z.ZodType<NavigationItem>`, documented at the declaration: every branch of the type is still `z.infer<typeof XNavItemSchema>`, so only the MEMBERSHIP of the list is unchecked, and app.test.ts parses all nine variants. - `AGENTS.md` — main added the `check:generated` aggregate section; this branch added `check:exported-any` to the "pure checks with no generator" list. Both are kept. Also registers `check:exported-any` in `check-generated.ts`'s ledger, which main added and which reconciles itself against package.json on every run. Registers `check:variant-docs` in the same ledger while here: #4177 added the script and #4183 added the reconciliation, neither PR could see the other, so `check:generated` was already failing on `main` itself. One line, in a file this branch was editing anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY3swmCsdYx6AMGiRmFt1H
2 parents 3f64131 + 74aa3f0 commit c955c56

117 files changed

Lines changed: 5309 additions & 724 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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
---
3+
4+
ci(temporal): run the non-SQL temporal backends under a skewed process zone too (#4081)
5+
6+
The `temporal-conformance` job pinned `TZ: America/New_York` on the live
7+
Postgres/MySQL sweep only. `core`, `formula`, `driver-memory`, `driver-mongodb`
8+
and `service-analytics` — the backends where a stray `getFullYear()` or a
9+
local-midnight `new Date(y, m, d)` is easiest to write and hardest to see — kept
10+
running in the runner's default UTC, where every offset bug is invisible because
11+
the offset is zero.
12+
13+
They now run in the same skewed zone, and both TZ-skewed steps carry a
14+
non-vacuity guard that fails the job if the process zone is UTC or the offset is
15+
zero, so the coverage cannot silently evaporate if the runner image changes.
16+
17+
CI configuration only; releases nothing.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(adr): ADR-0104 addendum — evidence for the remaining #3438 strict flips (D1 references/structured JSON gate on a per-deployment `os migrate value-shapes` scan; fresh datastores attest both flags at creation; the D2 action-param flip rides the 18.0 major with an escape hatch) — releases nothing.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): a `$or` / `$not` filter no longer vanishes from an analytics query (#4128 follow-up)
6+
7+
The last of the silently-dropped filter family. `normalizeAnalyticsFilters`
8+
produced a flat **array**, which cannot carry a disjunction, so both strategies
9+
skipped `$or` and `$not` outright — a widget or dataset whose filter used
10+
either compiled a WHERE clause that simply did not contain it, and drew every
11+
row. That is #3650's symptom, and unlike a rejected query it looks like a
12+
working chart.
13+
14+
The normalizer now produces a **tree** (`normalizeAnalyticsFilterTree`), and
15+
each strategy compiles it the way its own backend expresses a disjunction:
16+
17+
- **`NativeSQLStrategy`** builds the WHERE recursively, routing every leaf
18+
through its existing clause emitter — so the storage-form coercion and the
19+
calendar-day upper-bound rule (#3777) apply at every depth, including inside
20+
an `$or`. Parentheses are explicit rather than relying on SQL precedence.
21+
- **`ObjectQLStrategy`** hands `$or` / `$not` to the engine, which speaks them
22+
natively. AND-ed leaves still merge per field exactly as before, so a query
23+
without combinators produces byte-identical engine input.
24+
- **`/analytics/sql`** renders the same tree, so the echoed statement keeps
25+
reproducing what executes rather than showing a conjunction where the engine
26+
runs a disjunction.
27+
- The **cross-object envelope check** now sees members nested inside an `$or`.
28+
It rejects cross-object filters, so a member it could not see was a filter it
29+
could not reject.
30+
31+
Empty `$and` / `$or` arrays now throw instead of being ignored, matching the
32+
fail-closed stance of `read-scope-sql.ts` — the compiler in this same package
33+
that has always handled the full tree, and whose semantics the tree walker now
34+
mirrors deliberately.
35+
36+
Cover is `native-sql-filter-logic-conformance.test.ts`, which runs the shared
37+
combinator table (`FILTER_LOGIC_CASES`, #3774) against a real SQLite engine and
38+
asserts row ids. The analytics raw-SQL path now stands beside `driver-sql`,
39+
`driver-memory`, `formula` and `read-scope-sql` under that one standard; 14 of
40+
its 17 cases fail without this change.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
feat(spec)!: reject unknown keys across the app shell and navigation tree (#4001 app step, PR B)
7+
8+
Closes the last high-traffic authorable surface in the unknown-key strictness
9+
ratchet (flow + permission #4071, RLS / sharing / position #4099, approval
10+
#4119, App dead-key tombstones #4142). The app shell is the densest
11+
hand-authored surface on the platform — a navigation tree is where an author
12+
or AI is most likely to write a key from memory — so a silent strip here was
13+
the most probable instance of the #3405 trap.
14+
15+
- **`AppSchema`** and its sub-schemas (`AppBrandingSchema`,
16+
`NavigationAreaSchema`, `AppContextSelectorSchema` + its `optionsSource` /
17+
`filter` blocks, `NavigationContributionSchema`) are `.strict()`.
18+
- **`NavigationItemSchema` becomes a DISCRIMINATED union on `type`.** This is
19+
what makes strict readable: a plain union of strict members answers one
20+
unknown key with an `invalid_union` aggregate naming all nine branches,
21+
while discriminating on `type` first yields a single `unrecognized_keys`
22+
issue against the branch the author actually wrote — at an exact path
23+
through nested `children` — and a mistyped `type` gets its own "Invalid
24+
discriminator value". Each variant carries its own suggestion pool, so a
25+
`url` item is never told about `dashboardName`.
26+
- **Still OPEN by design:** `PageNavItem.params`, `ComponentNavItem.params`
27+
and `ActionNavItem.actionDef.params` — per-target payloads owned by the
28+
page / component / action, not by the nav item.
29+
30+
**A real defect the gate caught, in the platform's own app:** `ACCOUNT_APP`
31+
declared `defaultOpen` on three navigation groups. That was never a schema
32+
key — `expanded` is — so all three shipped COLLAPSED while their author
33+
believed they opened by default. Fixed at the producer (contract-first) and
34+
`defaultOpen` / `open` / `collapsed` / `isOpen` now alias to `expanded`.
35+
36+
**Migration.** Any key now rejected was previously stripped and had no
37+
runtime effect. The error carries the fix; mappings include
38+
`menu`/`sidebar`/`tabs`/`items``navigation`, `title``label`,
39+
`permissions``requiredPermissions`, `sort`/`position``order`,
40+
`defaultOpen``expanded`, `args``params` (actionDef), `primary`
41+
`primaryColor`, `url``endpoint` (options source), plus wrong-layer
42+
pointers: `pages`/`views`/`flows` are not App fields, and a payload named on
43+
the wrong variant points at the `type` that owns it.
44+
45+
The `visibleWhen``visible` alias is the load-bearing one: ADR-0089 made
46+
`visibleWhen` canonical on view/page schemas, so an author who learned it
47+
there would silently lose a nav entry's visibility gate — a capability gate
48+
failing open, the worst shape of the silent-strip bug.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the unknown-authoring-key lint covers every metadata collection, not just objects (#3786)
6+
7+
#4148 introduced the lint for `object` and `field` — the two surfaces #4120
8+
caught real drift on. Those two were a sample, not the population: of the
9+
authorable metadata types, only a handful are `.strict()` (`flow` / `permission`
10+
/ `position` / `tool` from #4001 Tier-A, joined mid-review by `app` via #4165).
11+
Every other type strips an
12+
undeclared key exactly the way `field` did — an author who misspells a key on a
13+
`page`, an `agent` or a `dashboard` got the same parse-clean-value-gone silence,
14+
with no lint watching.
15+
16+
`lintUnknownAuthoringKeys` now walks **every metadata collection** — 16 today:
17+
object, page, dashboard, report, dataset, action, job, agent, skill, hook,
18+
mapping, datasource, view, email_template, doc, book — and its coverage is
19+
**derived, not listed**: which collections exist comes from `PLURAL_TO_SINGULAR`
20+
(the same boundary map the normalizer uses), which schema judges each comes from
21+
the canonical type→Zod registry, and whether linting is even meaningful is read
22+
off each schema's own unknown-key posture. A third hand-written "types the lint
23+
covers" list would have been the #3786 shape all over again, inside the tool
24+
built to end it.
25+
26+
The posture rules keep the lint from ever disagreeing with the parse:
27+
28+
- **strip** (zod default) → lint: the parse drops unknown keys silently, and
29+
that silence is what gets reported.
30+
- **strict** → skip: the parse already rejects loudly with the schema's own
31+
tombstone guidance; a second, possibly disagreeing voice helps nobody. This
32+
bucket GROWS as #4001 tiers graduate schemas — `app` graduated (#4165) while
33+
this change was in review, and the derivation adapted without an edit.
34+
- **passthrough** → skip: unknown keys survive the parse, nothing is dropped.
35+
- **unions** (`view`) → the union of member keys; lintable only when a member
36+
strips and none passes unknowns through.
37+
38+
`defineStack`, `os validate` and `os build` pick the wider coverage up with no
39+
code change of their own. Verified against the three first-party example apps
40+
(28 pages, 29 flows, 11 actions and friends in the showcase): all clean, zero
41+
false positives. Verified by mutation: dropping union handling, inverting the
42+
strict filter, and skipping a collection each turn the tests red.
43+
44+
New root/kernel exports: `listLintableAuthoringCollections` (+
45+
`LintableAuthoringCollection`) — the derived coverage as data, so tooling can
46+
report what the evidence base for the #4001 strict tiers actually spans.
47+
48+
One import-site change: `lintUnknownAuthoringKeys` moved from the `/data`
49+
subpath to the package root and `/kernel` (`@objectstack/spec` root import is
50+
unchanged and remains the canonical site). Covering every type means importing
51+
every schema, and `/data` is consumed by frontend bundles — the walker moving
52+
out keeps that chunk from inheriting the whole schema universe. If you imported
53+
it from `@objectstack/spec/data`, import from `@objectstack/spec` instead. The
54+
comparator, guidance tables and finding types stay in `/data`, unchanged.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/service-job': patch
3+
---
4+
5+
Jobs registered before `kernel:ready` now survive the placeholder→DbJobAdapter upgrade.
6+
7+
Business plugins `start()` before the JobServicePlugin's `kernel:ready` hook, so every schedule they register lands on the placeholder IntervalJobAdapter. That placeholder silently ignores `cron` schedules, and the upgrade used `replaceService` without migrating anything — so in the default configuration a plugin's cron jobs never ran at all, while its interval timers kept running on the orphaned placeholder, invisible to `sys_job`. The upgrade now snapshots every early registration, stops the placeholder, and re-schedules them on the DbJobAdapter (whose croner-backed cron routing makes the cron entries actually fire). The IntervalJobAdapter also warns per cron registration, and the no-engine path summarizes stranded cron jobs instead of staying silent.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(analytics)!: a measure emits what it declares, instead of `COUNT(*)` (#4157)
6+
7+
`NativeSQLStrategy.resolveMeasureSql` answered `COUNT(*)` to three different
8+
questions it could not otherwise answer — each time aliased under the name the
9+
caller asked for, so the result looked like an answer:
10+
11+
1. **A measure the cube does not declare.** `lookupMember`'s synthetic
12+
relation fallback is dimension-only, so any undeclared or mistyped measure
13+
name landed here. `measures: ['revenue']` against a cube without it returned
14+
`COUNT(*) AS "revenue"` — a row count presented as revenue.
15+
2. **A `number`/`string`/`boolean` metric.** `AggregationMetricType` documents
16+
these as *"Custom SQL expression returning a number / string / boolean"*: the
17+
measure's `sql` **is** the computation — a ratio, a `CASE`, a window
18+
function. The expression was discarded and replaced by a row count.
19+
3. **An unrecognised `type`.** Same silent substitution.
20+
21+
Now: an undeclared measure and an unrecognised type **throw**, naming the
22+
declared measures and both accepted vocabularies respectively; a custom-
23+
expression type emits its expression unwrapped. The six aggregates are
24+
unchanged.
25+
26+
**A dot no longer implies a relationship hop.** `qualifyAndRegisterJoin` split
27+
any dotted string into a join chain, so the expression `SUM(account.amount)`
28+
became `"SUM(account"."amount)"` *plus* a `LEFT JOIN "SUM(account"` — invalid
29+
SQL naming a table that does not exist. Harmless only while the result was
30+
being thrown away for `COUNT(*)`; emitting the expression makes it matter. A
31+
dotted string is now treated as a path only when every segment is a bare
32+
identifier, so `account.amount` still lowers to a qualified column and a join,
33+
and an expression is emitted as written. That also fixes the same mangling for
34+
an *aggregate* measure whose `sql` is an expression — `type: 'sum'` with
35+
`sql: 'SUM(account.amount)'` was producing the same garbage.
36+
37+
**Breaking, narrowly.** Two inputs that used to produce SQL now raise: a query
38+
naming an undeclared measure, and a cube measure with a type outside
39+
`AggregationMetricType`. Both were returning a wrong number rather than data,
40+
so nothing correct can depend on them — but a caller that was silently getting
41+
row counts will now see an error, which is the point. This is the trade #3948
42+
settled for the drivers.
43+
44+
Datasets are unaffected: `aggregateToMetricType` only ever emits an
45+
`AggregationFunction` member, so a compiled dataset never had a
46+
custom-expression measure or an unknown type. The reachable path is a
47+
hand-authored Cube.
48+
49+
`metric-type-coverage.test.ts` asserts the aggregate and expression sets
50+
*partition* `AggregationMetricType`, so a tenth metric type fails a test rather
51+
than reaching the throw. Both sets are named, not derived as each other's
52+
complement — deriving would classify a new *aggregate* as an expression and emit
53+
a bare column, a different silent wrong answer.
54+
55+
Verified: **460 tests across 35 files** green, including the four suites that
56+
assert `COUNT(*)` — all of them use a *declared* `type: 'count'` metric, so none
57+
relied on a fallback. The 14 new tests were confirmed to fail against the old
58+
behaviour (6 of 10 in the behaviour suite) before the fix.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/service-messaging": patch
3+
"@objectstack/plugin-audit": patch
4+
---
5+
6+
fix(service-messaging,plugin-audit): the service that writes `sys_notification` is the one that declares it (#4154)
7+
8+
`MessagingService.emit()` writes `sys_notification` on every call — it is the
9+
pipeline's single ingress (ADR-0030 L2). But the object was contributed to the
10+
manifest by **`AuditPlugin`**, parked there with a comment saying it would stay
11+
"until that [ADR-0030] migration lands". The migration landed; the parking did
12+
not move.
13+
14+
That left a real deployment hole, because `AuditPlugin` is an **optional** pair
15+
in the CLI's plugin table. Install messaging without audit and nothing registers
16+
the object, so the engine has no schema to issue DDL from and every `notify()`
17+
fails with `no such table: sys_notification`. AuditPlugin never wrote the row
18+
itself — it deliberately routes through this service's `emit()` ingress
19+
(`getMessaging()` in `audit-writers.ts`), and its own exclusion list already
20+
annotates the object as "messaging-owned (ADR-0030)".
21+
22+
The contribution now lives with the writer, matching how every other
23+
service-owned platform object is handled in this repo — `service-job` imports
24+
`SysJob`/`SysJobRun`, `service-queue` imports `SysJobQueue`, `rest` imports
25+
`SysImportJob`. Ownership of the *definition* is unchanged: the object stays in
26+
`@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`, because
27+
owning a definition and contributing it to a running kernel are different
28+
things. It is also added to the service's `provisionSystemTables`, so the table
29+
is created with the rest of the pipeline it heads rather than lazily on the
30+
first write.
31+
32+
Found while migrating `notifications.hono.integration.test.ts` to in-memory
33+
SQLite in #4065: that suite had to register the object itself to boot, which was
34+
the deployment bug in miniature. The workaround is deleted in this change — the
35+
suite now boots messaging alone and passes, which is the proof the product
36+
declares what it writes.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/spec": minor
4+
"@objectstack/runtime": minor
5+
"@objectstack/objectql": patch
6+
"@objectstack/metadata": patch
7+
---
8+
9+
feat(core,runtime): plugin ordering is a declared, kernel-enforced contract (ADR-0116, #4131)
10+
11+
`kernel.use()` registration order was never a contract — the kernel resolves
12+
init/start order from the plugin dependency graph — but a plugin that needed a
13+
service at init *when its provider is composed* while also booting *without*
14+
the provider had no way to declare that. `AppPlugin` was the standing example:
15+
it grabs `manifest`/`objectql` synchronously in `init()`, declared nothing
16+
(a hard dependency would break empty-env / metadata-only / mock-engine
17+
kernels), and so its correctness rode on which array slot each caller put it
18+
in. That convention failed the same way twice (`DefaultDatasourcePlugin`'s
19+
first cut; then #4085, disguised for months as "crashes when the artifact is
20+
missing").
21+
22+
The kernel `Plugin` contract gains three additive fields, enforced by both
23+
`ObjectKernel` and `LiteKernel` through one shared implementation
24+
(`plugin-order.ts` — the previously duplicated topological sort is unified
25+
there):
26+
27+
- **`optionalDependencies: string[]`** — order-if-present: hoisted ahead
28+
exactly like `dependencies` when composed (real topology edges, including
29+
cycle detection), silently skipped when absent.
30+
- **`requiresServices: string[]`** — services resolved synchronously during
31+
`init()` with no fallback. Validated **before Phase 1**: a required service
32+
whose only declared provider initializes later fails the boot with an error
33+
naming both plugins, both slots, and the fix — before any init side
34+
effects. Re-checked immediately before the plugin's own init, where a still-
35+
missing service becomes a named composition error exactly where the old
36+
bare `Service not found` crash fired.
37+
- **`providesServices: string[]`** — services a plugin's `init()`
38+
unconditionally registers; powers the validation and the diagnostics.
39+
40+
Plugins that declare nothing get the diagnosis too: a `getService` miss
41+
during Phase 1 now appends which plugin was initializing and — when a
42+
composed plugin declares the service — who provides it and how to declare the
43+
ordering. The `Service '<name>' not found` prefix and the factory-backed
44+
`is async - use await` message are unchanged.
45+
46+
First adopters: `AppPlugin` declares
47+
`optionalDependencies: ['com.objectstack.engine.objectql']` +
48+
`requiresServices: ['manifest']` (cleared on the empty-env no-op path), so
49+
the #4085 composition — AppPlugin registered before the engine — now boots
50+
correctly in every slot; `ObjectQLPlugin` declares
51+
`providesServices: ['objectql', 'data', 'manifest', 'lifecycle']` and
52+
`MetadataPlugin` declares `providesServices: ['metadata']`.
53+
54+
Everything is additive — plugins that declare nothing keep their exact
55+
ordering semantics; no existing declaration changes meaning.

0 commit comments

Comments
 (0)