Skip to content

Commit 6164355

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/envelope-drift-route-modules-v2zoky
2 parents 4aa9e67 + 8b03102 commit 6164355

60 files changed

Lines changed: 3157 additions & 216 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(adr-0115): record the #4093 evaluation verdict — plugin-dev's fill-every-slot stub table is retired as a design; the plugin converges to pure assembly. Tiered disposition (fabricators first with a production guard; core's fallback family becomes the one copy with #4089; the working in-memory tier points at the real packages that already exist), sequenced behind the in-flight #4086. Implementation tracked in #4104. Documentation only; releases nothing.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/cli': patch
6+
---
7+
8+
**Correct the stale premise left behind by #4012: the degraded-boot stderr copy
9+
survives the operator's LOG LEVEL, not `os serve`'s boot-quiet window.**
10+
11+
`emitDegradedBootBanner` writes the `OS_ALLOW_DRIVER_CONNECT_FAILURE` banner to
12+
stderr in addition to `logger.warn`, and every comment and test name explaining
13+
why cited the same reason: `os serve` swallowed all of stdout while the kernel
14+
booted, and `Logger` routes `warn` to stdout. #4012 fixed that — the boot window
15+
now buffers and replays `warn`-and-above — which retires the *stated*
16+
justification for a duplicate that is nonetheless still load-bearing:
17+
18+
`Logger.write()` returns before touching a stream when the record is below
19+
`config.level`, so at `--log-level error`, `fatal` or `silent` the banner's
20+
`logger.warn` reaches **no** stream at all. A production host at `error` is
21+
exactly the deployment this escape hatch exists for, and exactly where a
22+
logger-only banner would vanish. Removing the stderr copy on the strength of
23+
#4012 would therefore have been a regression — so this documents the reason that
24+
is still true, in the places someone would read before deleting it:
25+
`degraded-boot.ts`, the engine's emit site, and all three parity tests
26+
(objectql, runtime, service-datasource), which are renamed off "which `os serve`
27+
boot-quiet cannot swallow" to "which the operator log level cannot filter away".
28+
29+
The objectql parity test now proves the claim instead of asserting around it: it
30+
drives a **real** `ObjectLogger` at `level: 'error'` and requires the banner on
31+
stderr *and* nothing on stdout. Set the level to `warn` and it fails — so the
32+
test is pinned to the level filter rather than passing for any reason.
33+
34+
Also corrected in the same sweep, all comment-only, all previously overstating
35+
what #4012 had not yet fixed:
36+
37+
- the automation wiring summary (`format.ts`, `serve.ts`, its test) claimed the
38+
boot window swallowed the engine's binding warnings. Its real justification is
39+
stronger and unchanged: a flow that silently fails to arm emits **no** log line
40+
at any level, so binding state has to be read off the live engine — absence of
41+
a warning was never evidence of a bound flow.
42+
- the seed summary (`seed-summary.ts`, `format.ts`, its test) and `AppPlugin`'s
43+
seed-outcome note attributed the silence to the boot window; the operative
44+
gate is that `SeedLoader`'s result logs are `info`, under the default `warn`.
45+
46+
No behavior changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
Gate every dispatcher service domain on `handlerReady` instead of on slot occupancy (#4058 step 2).
7+
8+
#4000 made the `/analytics` domain execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability"); every other domain still gated on "is a service registered", so a self-declared stub occupying `automation` / `notification` / `ai` / `file-storage` / `i18n` was called like a real implementation and its fabricated answer went out as a 200. Step 1 (#4082) made the two kinds of dev implementation distinguishable; this is the gate that reads the distinction.
9+
10+
- The `/analytics`, `/automation`, `/notifications`, `/ai`, `/storage` and `/i18n` domains, the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement now share one predicate (`isServiceServeable`): a slot whose occupant self-declares `handlerReady: false` is answered exactly as an empty slot is — the domain's existing 404, or the explicit 501 `/storage` and `/i18n` use. One predicate, so what is advertised and what is served cannot disagree.
11+
- `handlerReady`, not `status`, is the test. An implementation that declares `degraded` defaults to `handlerReady: true` and keeps serving — which is why the in-memory `file-storage` and `i18n` implementations are unaffected.
12+
- `discovery.services.*` stays presence-gated: a registered stub still reports `{ enabled: true, status: 'stub', handlerReady: false }` (with no `route`), which says strictly more than collapsing it to `unavailable` would.
13+
- `/ai` improves for the stub case: an occupied-but-unserveable slot used to fall through to a 503 "AI service routes not yet initialized" and lose the `GET /ai/agents` empty-list answer the console polls for on every navigation. Both are restored.
14+
15+
No change for a host whose services are real implementations. If you register your own stub under one of those six slots and relied on the dispatcher calling it, either drop the `handlerReady: false` self-declaration (declare `degraded` if it genuinely serves) or install the real service. Not gated, deliberately: `/data`, `/meta`, `/auth` and the security path — their dev stubs back the dev stack's own core loop, and gating them would 404 the dev stack itself.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/rest": minor
5+
"@objectstack/runtime": patch
6+
---
7+
8+
fix(spec,objectql,rest,runtime): field-validation messages answer in the caller's language, named by the field's label (#3957)
9+
10+
The write path built every built-in validation message by concatenating the **API
11+
field name** into a **hardcoded English** template. Those strings are what the
12+
Console toast, the CSV-import row report, the CLI and any custom client display
13+
verbatim, so a Chinese-locale user importing a bad row read:
14+
15+
```
16+
第 1 行:penalty_amount must be ≥ 0
17+
```
18+
19+
…for a field declared `label: '处罚金额'` with a full `zh-CN` bundle loaded. The
20+
form layer localized the *same* constraint correctly (the browser's native
21+
`min`), so the language flipped depending on which layer caught the value.
22+
23+
**Three things changed.**
24+
25+
1. **The message is rendered in the caller's locale** from a built-in catalog
26+
(`BUILTIN_VALIDATION_MESSAGES`, `@objectstack/spec/system`) shipping `en`,
27+
`zh-CN`, `ja-JP`, `es-ES` — the same four locales as the platform bundles.
28+
The locale comes from `ExecutionContext.locale`, whose contract already read
29+
"Drives message catalogs"; this is the consumer that makes that true. Both
30+
HTTP entries (REST server, runtime dispatcher) now resolve it from the
31+
request's `Accept-Language` / `?locale` first, falling back to the workspace
32+
`localization.locale` — so a rejection message and the field labels around it
33+
can no longer disagree.
34+
35+
2. **The field is named by its label, never the API name**: translation bundle
36+
(`objects.<obj>.fields.<f>.label`) → declared `label` → API name as the last
37+
resort. `FieldValidationError.field` still carries the API name so a form can
38+
focus the right input.
39+
40+
3. **The constraint is exposed as data**, so a client can format its own text
41+
instead of parsing the sentence:
42+
`{ field, code, message, label, constraint: { min: 0 } }`. This rides
43+
ADR-0114's existing `constraint` / `value` positions on `FieldErrorSchema`
44+
(`constraint` tightens from `unknown` to `Record<string, unknown>`) rather
45+
than adding a parallel payload — `label` is the only new field. The bag
46+
carries `min`/`max`/`minLength`/`maxLength`/`actual`/`allowed`/`type`, and the
47+
message templates interpolate from exactly those keys.
48+
49+
Covered end-to-end, not only in the validator: single and batch insert,
50+
single-id and multi-row update, ADR-0113's clear-out rejection, the object-level
51+
rule evaluator's own built-in messages (`requiredWhen`, per-option gating,
52+
state-machine fallbacks), and the importer's cell-coercion, required pre-check
53+
and #3956 bound pre-check messages — all of which land in the same row report.
54+
55+
**What this changes for consumers.**
56+
57+
- `code` is unchanged (ADR-0114's `FieldErrorCode`) and remains the thing to
58+
match on. Message keys are finer-grained than codes — `invalid_datetime`,
59+
`invalid_option_value`, `required_cleared` are rendering detail and never reach
60+
the wire — so localization never splits the client-facing vocabulary.
61+
- `message` **text changes**: it is localized, and it names the field by label
62+
even in English (`Budget must be ≥ 0`, not `budget must be ≥ 0`). Anything
63+
asserting on the old English string should match `code` (and now
64+
`constraint`) instead.
65+
- An author-written validation-rule `message` is never touched — it is already
66+
in the language its author chose.
67+
- A deployment can override any built-in message with a `translation` item
68+
defining `validation.field.<messageKey>` (e.g.
69+
`validation.field.min_value: '{{label}}不得小于 {{min}} 元'`).
70+
- The importer's reference-failure message no longer names the target object's
71+
API name (`no sys_user matches "…"`): naming internal identifiers is the
72+
defect being fixed, and the column plus the offending value are what an
73+
importer can act on.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
---
4+
5+
fix(datasource): a `memory` datasource is ephemeral again, and each pool gets its own store (#4083)
6+
7+
The shared driver factory built `new InMemoryDriver()` for `driver: 'memory'` with
8+
no config, so the pool inherited that driver's own `persistence: 'auto'` default —
9+
in Node, a file adapter at the **relative, process-global** path
10+
`.objectstack/data/memory-driver.json`. Two consequences, neither intended:
11+
12+
- **It was not ephemeral.** The pool flushed its whole store into the server's
13+
working directory (on an unref'd 2s autosave timer, and again at teardown) and
14+
reloaded it on the next boot. That is the opposite of what the driver id
15+
promises the operator who asks for it — `OS_DATABASE_DRIVER=memory` is
16+
documented as *ephemeral, not real SQL* — and it means a "throwaway" datasource
17+
left state in the deploy directory.
18+
- **Every memory pool in a process shared one destination.** The default path
19+
carries no per-datasource component, so two `driver: 'memory'` datasources
20+
loaded and saved the same file: each saw the other's tables, and the last
21+
teardown to flush clobbered the other's rows.
22+
23+
Both were visible as an intermittent test failure. The ADR-0062 D1 federated-read
24+
acceptance seeds 2 rows into an auto-connected external memory datasource and
25+
reads them back; it returned 2 rows on a clean checkout and 2×N on the Nth run in
26+
the same tree — passing in CI (always run #1, always a fresh checkout) and
27+
failing locally for anyone who ran it twice. Whether a given run leaked depended
28+
on the autosave timer, which is what made it look flaky rather than wrong.
29+
30+
- The factory now builds the memory pool with **`persistence: false` by default**.
31+
- It also **honors the datasource's own `config`**, which was previously dropped
32+
entirely: `initialData` and `strictMode` never reached the driver.
33+
- When an author *does* opt into persistence (`config.persistence`), the default
34+
destination is **scoped to the datasource**
35+
`.objectstack/data/memory-<name>.json` / `objectstack:memory-db:<name>` — so
36+
pools stay independent. An explicit `path`/`key`, or a custom `adapter`, is
37+
left exactly as written.
38+
- The dev-only sqlite step-down's last-resort in-memory driver
39+
(`resolveSqliteDriver`, #2229) is built the same way, making its own
40+
"not persistent" contract true.
41+
42+
`InMemoryDriver`'s documented defaults are unchanged — constructing one directly
43+
still auto-detects persistence. Only the datasource-scoped pools this factory
44+
builds changed.
45+
46+
**Migration.** A deployment relying on `driver: 'memory'` state surviving a
47+
restart was relying on a bug, and should declare it: set
48+
`config: { persistence: 'file' }` on the datasource (now written to a
49+
per-datasource file), or use a real driver — `sqlite`/`sqlite-wasm` give durable
50+
storage with real SQL. Existing `.objectstack/data/memory-driver.json` files are
51+
no longer read; delete them.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
feat(metadata): `saveMeta` persists the operator spellings the spec normalized (objectui#2945)
6+
7+
`ViewFilterRuleSchema.operator` is `z.preprocess(normalizeFilterOperator, …)`, so
8+
a stored `notEquals` / `gt` / `isNull` is folded to its canonical form during
9+
save-time validation — and then the result was thrown away. `saveMetaItem`
10+
persists the authored body verbatim, deliberately: `parsed.data` strips the
11+
Studio-only auxiliary fields (`isPinned`, `isDefault`, `sortOrder`) that ride
12+
along with an overlay document (ADR-0005 §Validation).
13+
14+
The consequence is that **every save mints new legacy-alias rows.** The ~30
15+
entries in `VIEW_FILTER_OPERATOR_ALIASES` are documented as *"a migration bridge
16+
[that] may be dropped in a future major"*, but there is no point at which the
17+
last alias row is behind you, so the bridge can never be dismantled — a
18+
migration that rewrote every existing row would be obsolete the moment the next
19+
console personalization PUT landed. That is prerequisite 2 of the vocabulary
20+
consolidation blocked in objectstack-ai/objectui#2945.
21+
22+
`graftNormalizedOperators` grafts the normalization back on without giving up the
23+
verbatim body. It walks the authored value and `parsed.data` in lockstep **by
24+
structure** and copies across exactly one thing: an `operator` whose parsed value
25+
differs from the authored one.
26+
27+
- **No key list to maintain.** `ViewFilterRule[]` appears at five declared sites
28+
today (view `filter`, `ViewTab.filter`, page `filterBy`, and two
29+
`component.zod.ts` block props) and the structural walk covers all of them,
30+
plus any added later. Enumerating paths would have reproduced in this file the
31+
exact duplication #2945 exists to remove.
32+
- **Nothing else moves.** Only an `operator` string is rewritten, and only where
33+
both sides are strings — so a `$`-token `FilterCondition`, a different operator
34+
vocabulary entirely, cannot be reshaped by accident. No key is added, removed,
35+
reordered or defaulted; the unary `{field, operator}` form does not acquire a
36+
`value` even though the schema's own output would give it one.
37+
- **Nothing is allocated when nothing changed**, so a body already written in
38+
canonical form is returned by identity.
39+
40+
Behaviour change worth stating plainly: a `GET` after a `PUT` now returns the
41+
canonical spelling rather than the one the author sent. That is the spelling the
42+
spec defines, every renderer accepts it (objectstack-ai/objectui#2974,
43+
objectstack-ai/objectui#2989 pinned all three of objectui's translation tables to
44+
the full vocabulary), and it is the point of the change. Existing rows are not
45+
touched — this stops the bleeding, it is not the migration.
46+
47+
Verified: 11 new tests, including one that drives **every** alias the spec still
48+
folds through the real `ViewMetadataSchema` and asserts the persisted body comes
49+
out canonical; full `@objectstack/metadata-protocol` suite 110 tests / 18 files
50+
green.

content/docs/kernel/services-checklist.mdx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,32 @@ The discovery endpoint returns a `services` map so clients know what is availabl
135135
}
136136
```
137137

138+
#### `stub` vs `degraded` — and what the dispatcher does with each
139+
140+
A service may self-declare that it is not the full thing (ADR-0076 D12), via a
141+
`__serviceInfo: { status, handlerReady?, message? }` property on the registered
142+
instance — see [Services → presence is not capability](./services.mdx) for how
143+
an in-process caller reads it. The two values mean different things, and for the
144+
HTTP surface the difference is load-bearing (#4058):
145+
146+
| Declared | `handlerReady` default | Meaning | Dispatcher behaviour |
147+
|:---|:---|:---|:---|
148+
| `degraded` | `true` | Really does the work, with reduced capability (in-memory, no persistence, no cross-process fan-out) | **Served normally.** Route advertised, requests handled. |
149+
| `stub` | `false` | Fabricates its answers — reports success for work that never happened | **Treated as an EMPTY slot.** Route not advertised, request answered exactly as if nothing were registered (404, or the domain's 501). |
150+
151+
Only `handlerReady` is consulted, never `status` — so an implementation that
152+
declares `degraded` but explicitly sets `handlerReady: false` is also treated as
153+
empty. The rule is enforced by one predicate (`isServiceServeable`) shared by
154+
the service domains, the route-mount gate, and both discovery builders, so what
155+
is advertised and what is served cannot disagree. It applies to the
156+
dispatcher-owned domains: `/analytics`, `/automation`, `/notifications`, `/ai`,
157+
`/storage`, `/i18n`.
158+
159+
The `services` map still reports a registered stub as
160+
`{ enabled: true, status: "stub", handlerReady: false }` rather than collapsing
161+
it to `unavailable` — "something is in this slot, and it is a fake" says more
162+
than "install a plugin".
163+
138164
---
139165

140166
## 2. data Service ✅ Implemented
@@ -193,6 +219,13 @@ self-declared stub (`handlerReady: false`, ADR-0076 D12) exactly like an empty
193219
one — routes unmounted, request 404. To use analytics locally, install the real
194220
engine; `@objectstack/service-analytics` runs an InMemory strategy, so it needs
195221
no database of its own.
222+
223+
**And it is no longer analytics-only** (#4058 step 2): every dispatcher-owned
224+
domain now gates on `handlerReady`, so a slot occupied by a self-declared `stub`
225+
`automation`, `notification`, `ai`, wherever one is registered — answers as an
226+
empty slot does. The implementations that really do the work in memory declare
227+
`degraded` and keep serving. See
228+
[`stub` vs `degraded`](#stub-vs-degraded--and-what-the-dispatcher-does-with-each).
196229
</Callout>
197230

198231
### Features (via `@objectstack/service-analytics`)

0 commit comments

Comments
 (0)