Skip to content

Commit bea79a9

Browse files
committed
Merge origin/main — reconcile with ADR-0115 (#4137): plugin-dev half superseded, runtime /ui discovery gate kept
#4137 retired the entire stub table wholesale (Tiers A+B+C), which includes the data and ui retirements this branch carried incrementally — plugin-dev files resolve to main's state. What this branch still adds is the runtime seam #4137 deliberately left untouched: discovery's routes.ui/services.ui gate on the protocol service that actually serves /ui, which matters more post-ADR-0115 because no boot registers a ui-slot occupant at all now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B
2 parents cd72b6b + 1caf30c commit bea79a9

62 files changed

Lines changed: 2443 additions & 1154 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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): a `$between` analytics filter no longer vanishes from the query (ADR-0053 D-A3.1)
6+
7+
A dashboard widget or dataset whose filter used `$between` was querying **every
8+
row**. `normalizeAnalyticsFilters` maps Mongo-style operators onto the internal
9+
pipeline form, `$between` was missing from that map, and an unmapped operator is
10+
skipped — so the predicate was silently dropped from the compiled WHERE clause.
11+
Both strategies read that normalizer, so both the raw-SQL and the ObjectQL
12+
aggregate paths were affected. The symptom is #3650's: a chart that draws the
13+
whole dataset instead of the requested window, with nothing in the SQL to
14+
suggest a filter was ever asked for.
15+
16+
`$between [min, max]` now lowers to its two bounds (`gte` + `lte`) instead of
17+
gaining an operator of its own, so a range's max inherits the calendar-day
18+
whole-day rule (#3777) from each strategy's existing upper-bound handling —
19+
`NativeSQLStrategy` compiles a bare-day upper bound half-open itself, and the
20+
ObjectQL path gets the same rule from the driver — rather than needing a second
21+
implementation to keep in step. A malformed `$between` (not a two-element
22+
array) now throws instead of being dropped, matching the stance driver-memory
23+
took for the same shape in #3948: an unbounded read is exactly the failure this
24+
prevents, and it is indistinguishable from a legitimately wide query.
25+
26+
Found by giving the temporal conformance matrix its missing sixth consumer
27+
(`native-sql-temporal-conformance.test.ts`), which executes the shared cases
28+
against a real SQLite engine and asserts row ids — a dropped predicate is
29+
invisible to the SQL-string assertions the strategy's other suites use.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): every authorable filter operator now reaches the query (#4128)
6+
7+
Closes the cause behind the `$between` defect rather than just that instance.
8+
`normalizeAnalyticsFilters` skipped any operator missing from its map, and a
9+
skipped predicate does not narrow a query — it **widens** it: the compiled SQL
10+
stays valid and returns rows the author excluded. Four operators from the
11+
spec's authorable vocabulary sat in that state, plus one that was mapped
12+
incorrectly.
13+
14+
- **`$startsWith` / `$endsWith`** were dropped entirely. Both strategies now
15+
compile them — anchored `LIKE 'x%'` / `LIKE '%x'` on the raw-SQL path, and
16+
the canonical `$startsWith` / `$endsWith` operators (which every driver
17+
implements directly) on the ObjectQL path, so an anchored match does not
18+
depend on regex dialect.
19+
- **`$null`** was dropped. It is the shape the console emits for an "is empty"
20+
/ "is not empty" filter, so such a widget was showing every row. Now compiles
21+
to `IS NULL` / `IS NOT NULL` per its boolean.
22+
- **`$exists`** was mapped value-*independently* to `set`, so `{$exists: false}`
23+
compiled to `IS NOT NULL` — the exact inverse of what it asks for. It and
24+
`$null` are now resolved explicitly, because a key→name map cannot express an
25+
operator whose meaning flips with its value.
26+
- **`$notContains`** reached the ObjectQL strategy, which had no arm for it and
27+
fell through to a `default` returning a bare value — compiling "does not
28+
contain x" as "**equals** x".
29+
- **Unknown operators now throw** on both surfaces instead of being silently
30+
dropped (normalizer) or reinterpreted as an equality (ObjectQL strategy). An
31+
operator outside the vocabulary is a caller error, and a loud one beats a
32+
silently widened read — the call driver-memory made for the same shape in
33+
#3948.
34+
35+
Still declared as a gap, but no longer a silent one: `$or` / `$not` are skipped,
36+
since expressing them needs a recursive WHERE builder rather than the flat
37+
array the strategies consume.
38+
39+
Cover is `filter-operator-coverage.test.ts`, which runs the whole vocabulary
40+
against a real SQLite engine and asserts **row ids** — six of its cases fail
41+
without this change. A dropped predicate is invisible to the SQL-string
42+
assertions the strategies' other suites use, which is how these survived.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: tombstone the seven dead AppSchema authoring keys (#4001 app step, PR A)
6+
7+
The 2026-06 AppSchema liveness audit verdicted seven authorable keys DEAD —
8+
never read by any consumer in framework or objectui. Authoring them shipped
9+
config the author believed was in effect; `sharing`/`embed` were the
10+
dangerous case (a declared public-access surface no route enforced,
11+
ADR-0049 class). All seven are now `retiredKey()` tombstones: `tsc`-level
12+
`never` for typed authors, a parse-time prescription for everyone else —
13+
NOT a silent strip, because `AppSchema` is not yet `.strict()`.
14+
15+
**Removed keys and their prescriptions (FROM → TO):**
16+
17+
- `App.version` → an app is versioned by its owning package: `manifest.version`.
18+
- `App.aria` → declare `aria` on the component/widget that renders the DOM node.
19+
- `App.objects` / `App.apis` → the self-described "config file convenience";
20+
objects/apis belong to the stack (`defineStack({ objects, apis })`) — the
21+
chatbot derives an app's object list from its NAV ITEMS, never from these.
22+
- `App.sharing` / `App.embed` → public access is granted per FORM VIEW
23+
(`FormView.sharing`, the public-data-collection surface); no public-app or
24+
iframe route ever read the app-level blocks.
25+
- `App.mobileNavigation` → fully unimplemented (even `packages/mobile`
26+
ignored it); returns if/when a real mobile navigation ships.
27+
28+
Deleting a key is behavior-preserving by construction — none ever had a
29+
runtime effect. `os migrate meta --from <16 or lower>` rewrites your source
30+
(the `app-dead-authoring-keys-removed` conversion, ADR-0087), or delete the
31+
keys by hand.
32+
33+
This clears the ADR-0049 precondition for PR B (AppSchema + navigation-union
34+
`.strict()`): strictness should guard the real contract, not dead keys.

.changeset/dev-plugin-data-ui-slots-and-ui-route-gate.md

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": patch
4+
"@objectstack/service-i18n": patch
5+
---
6+
7+
fix(spec,runtime,service-i18n): the dispatcher domains and their service contracts describe the same surface (#4127)
8+
9+
#4087 retired a `/storage` bridge that called `upload(key, data, options?)` as
10+
`upload(file, { request })` — a shape no implementation has. Sweeping the other
11+
dispatcher domains against `packages/spec/src/contracts/*` found the mirror-image
12+
gap in three places: the call site and the implementation agreed, and the
13+
**contract** was the thing that had never been written down. Each one was worked
14+
around at the call site with `typeof x.foo === 'function'` — a duck-type is what
15+
"the contract does not cover this" looks like when nobody fixes the contract.
16+
17+
Fixed at the contract, per Prime Directive #12.
18+
19+
**`INotificationService` — the inbox half.** `listInbox` / `markRead` /
20+
`markAllRead` now exist, with `InboxQuery` / `InboxNotification` /
21+
`InboxListResult` / `MarkReadResult`. Three SDK-expressed routes
22+
(`notifications.list` / `.markRead` / `.markAllRead`) have rested on them all
23+
along, implemented by `service-messaging`, while this contract described only
24+
`send`. The cost was not theoretical: the dev notification stub implements
25+
exactly `send` and `sendBatch` **because it followed the contract**, so the one
26+
implementation written to spec was the one the dispatcher had to duck-type past.
27+
28+
They are optional, and the probe stays: an inbox needs a durable store, and a
29+
send-only provider (SMTP, Twilio, a Slack webhook) fills the slot legitimately
30+
without one. `handlerReady` cannot express that — the slot is serveable, one
31+
capability of it is absent. The `/notifications` domain now takes
32+
`INotificationService` instead of `as any`, and each write route probes its own
33+
method rather than riding the entry `listInbox` check (they are separately
34+
optional, so "has an inbox to read" never implied "has read-state to write").
35+
36+
**`II18nService.getFieldLabels`.** Both serving surfaces — the dispatcher's
37+
`/i18n/labels/:object/:locale` and service-i18n's own mount — probed for it and
38+
both documented it as "optional on `II18nService`", which was not true. It is
39+
now. service-i18n's probe loses two casts with it (one through
40+
`Record<string, unknown>`, one re-declaring the signature inline).
41+
42+
**`IAutomationService.getFlowRuntimeStates`** + the `FlowRuntimeState` type.
43+
`GET /automation/_status` (and the CLI boot summary, and the
44+
`kernel:bootstrapped` audit) already called it while the contract stopped at
45+
`listFlows(): string[]`. The dispatcher's inline cast declared it as
46+
`{ name, enabled, bound }` — a third copy of the shape and a narrower one than
47+
the engine returns, dropping the `status` / `triggerType` / `object` fields that
48+
say WHY a flow is unbound.
49+
50+
Two runtime fixes fell out of the same sweep:
51+
52+
- **`POST /automation/trigger/:name` now builds a real `AutomationContext`.**
53+
It passed the raw HTTP body to `execute(name, body)`, so the
54+
`{ recordId, objectName, params }` translation never ran and — the sharper
55+
half — no caller identity was forwarded. A flow's default `runAs` is `'user'`,
56+
and a `runAs:'user'` run whose trigger resolved no user has its data
57+
operations REFUSED (#3760, fail-closed), so `client.automation.trigger()`
58+
could not run a data-touching flow at all while `POST /:name/trigger` could.
59+
service-automation's own comment claims "most trigger surfaces (REST action /
60+
trigger endpoint) already resolve the full envelope"; for this endpoint it was
61+
not true. Both routes share one context builder now.
62+
- **The dead `automationService.trigger(...)` probe is gone.** Nothing in the
63+
repo has ever implemented `trigger` on the automation slot and the contract
64+
never declared it, so the branch was unreachable on every deployment and its
65+
`execute` "fallback" was the route. Declaring `trigger?` would have blessed a
66+
second name for `execute`; the dead branch is deleted instead.
67+
68+
No migration. Every added contract member is optional, so existing
69+
implementations stay valid; the two runtime fixes only make routes that were
70+
failing or degraded behave like their working twins.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/plugin-dev': minor
3+
---
4+
5+
feat(plugin-dev)!: the stub table is retired — DevPlugin assembles real plugins and registers no service implementations of its own (ADR-0115, #4093, #4104).
6+
7+
DevPlugin used to fill every core-service slot no real plugin occupied with a dev stub. Every one of those stubs is gone. A slot nothing fills now stays EMPTY, exactly as in production: routes answer 404/501, discovery reports `unavailable`, and in-process consumers must handle absence — which production already required of them. FROM → TO per retired slot:
8+
9+
| Slot | The stub did | Instead |
10+
|:---|:---|:---|
11+
| `security.permissions` | allow-all `checkObjectPermission()` | install `@objectstack/plugin-security` (already part of the default assembly) |
12+
| `security.rls` | compiled no row filter | same — `plugin-security` |
13+
| `security.fieldMasker` | returned results unmasked | same — `plugin-security` |
14+
| `auth` | `verify()` accepted everyone as admin | install `@objectstack/plugin-auth` (already part of the default assembly) |
15+
| `data` | accepted writes, stored nothing | install `@objectstack/objectql` (already part of the default assembly) |
16+
| `ui` | shapeless `{}` placeholder | nothing consumed it; handle the absent slot |
17+
| `ai` | placeholder chat/complete answers | install a real AI service |
18+
| `automation` | `execute()` reported success without running | install an automation engine plugin |
19+
| `notification` | claimed "sent", delivered nothing | install a notification service |
20+
| `file-storage` | in-memory files lost on restart | `@objectstack/service-storage` — now auto-wired by DevPlugin when installed (local-disk adapter) |
21+
| `realtime` | in-process pub/sub copy | `@objectstack/service-realtime` — now auto-wired by DevPlugin when installed (its default in-memory adapter) |
22+
| `search` | in-memory substring index | no consumer resolves this slot; a future search service ships its own dev strategy |
23+
| `workflow` | unvalidated state transitions | no consumer resolves this slot; a future workflow service ships its own dev strategy |
24+
| `metadata` | a second hand-written copy of core's `createMemoryMetadata` | no behavior change — the kernel pre-injects core's fallback for empty core slots (`CORE_FALLBACK_FACTORIES`), and ObjectQL registers the real metadata service in the default assembly |
25+
| `cache` / `queue` / `job` / `i18n` | re-registered core's `createMemory*` fallbacks | no behavior change — the kernel pre-injects the same core fallbacks automatically; install `@objectstack/service-cache` / `service-queue` / `service-job` for real engines, and i18n auto-wires from the stack's translations (unchanged) |
26+
27+
Also new, from the same ADR:
28+
29+
- **Production guard** (first shipped with the security-trio subset): `DevPlugin.init()` throws when `NODE_ENV === 'production'` — the assembly is built around a well-known default auth secret and a seeded dev admin. Escape hatch: `OS_ALLOW_DEV_PLUGIN=1`.
30+
- **Assembly auto-wire**: `@objectstack/service-storage` and `@objectstack/service-realtime` are wired as optional child plugins when installed (both ship with DevPlugin's dependencies), so dev keeps working file storage and realtime through real implementations.
31+
- `options.services` keys for the retired stubs are accepted and ignored; `'file-storage'` / `'realtime'` now toggle the real service wiring.
32+
33+
One-line fix for an upgrading stack: if something you called in dev now throws "service not found" or 404s, that call was consuming a fabricated answer — install the real service for that slot (table above), or make the caller tolerate absence the way it already must in production.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(cli,runtime): an artifact you NAMED and a boot input you don't have are different failures — say which (#4110 follow-up, #4131 step 1)
7+
8+
Three corrections, all from the same principle: a platform may boot with no
9+
application (#4085), and that says nothing about how a MISSING NAMED INPUT
10+
should be read.
11+
12+
- **A named-but-missing artifact boots empty and silently.** #4110 made an
13+
absent artifact non-fatal all the way down — right for the conventional
14+
`<cwd>/dist/objectstack.json`, which is just "not compiled yet". But
15+
`OS_ARTIFACT_PATH` / `{ artifactPath }` skip the existence check by design, so
16+
the tolerance reached them too: `OS_ARTIFACT_PATH=/nope os serve` printed
17+
"booting from artifact", reached `Server is ready`, and named the missing path
18+
NOWHERE in its output (serve's boot-quiet window drops the loader's calm
19+
line). `createDefaultHostConfig` — the boot with no config, where the artifact
20+
IS the deployment — now rejects a named local artifact that does not exist,
21+
naming both the path and which source named it. The loader keeps its
22+
tolerance, so the config-boot path #4085 fixed is untouched.
23+
24+
- **"Configuration file not found" never said where it looked.** The two things
25+
that actually happen are a typo'd filename and the wrong working directory,
26+
and the second is the common one. It now names the config path, the artifact
27+
path, and that `OS_ARTIFACT_PATH` is unset — and still refuses rather than
28+
inventing a zero-object platform, pointing at `objectstack start` for a boot
29+
that is app-less on purpose.
30+
31+
- **That refusal was being truncated.** `this.exit(1)` unwinds to oclif's
32+
`process.exit`, which does not drain a piped stdout, so a diagnostic split
33+
across several `console.log` calls loses its tail — measured: only the first
34+
two lines of the new message survived a pipe, i.e. exactly the part that says
35+
where to look went missing. Both of `serve`'s pre-flight refusals now emit one
36+
write. Caught by the e2e added here, not by review.
37+
38+
Also corrects the plugin-ordering claims in `createStandaloneStack` and in the
39+
test that pinned them: the comment said the datasource plugin's array position
40+
"MUST precede ObjectQLPlugin: its start() connects the default driver", and the
41+
test asserted that index with the same rationale. The connect happens in
42+
`init()`, and the kernel resolves order from the dependency graph — which hoists
43+
ObjectQLPlugin ahead of the datasource plugin (measured: 6 slots earlier), the
44+
reverse of what the slot reads as. The test now pins the declared dependency
45+
that actually orders the two inits, which deleting the array position cannot
46+
break and deleting the declaration does. #4131 tracks making the AppPlugin end
47+
of that contract enforced rather than conventional.

0 commit comments

Comments
 (0)