Skip to content

Commit 6a381db

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/plugin-boot-logs-visibility-12kkwh
2 parents 461b584 + 3df8819 commit 6a381db

60 files changed

Lines changed: 3414 additions & 366 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: 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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/hono": minor
4+
"@objectstack/plugin-dev": patch
5+
---
6+
7+
fix(runtime,hono,plugin-dev): retire the dispatcher's `/storage` bridge — it never spoke the storage contract (#4087)
8+
9+
`POST /api/v1/storage/upload` and `GET /api/v1/storage/file/:id` were a
10+
dispatcher-side bridge to the `file-storage` service slot, written against a
11+
service shape that does not exist:
12+
13+
- **Upload** called the contract's `upload(key, data, options?)` as
14+
`upload(file, { request })` — the parsed file object landed in the `key`
15+
slot and `{ request }` in `data`. That is a `TypeError` against every
16+
implementation in the repo (`S3StorageAdapter`, `LocalStorageAdapter`,
17+
`SwappableStorageService`, plugin-dev's in-memory one), not a
18+
near-miss: `Buffer.from({}) → ERR_INVALID_ARG_TYPE`, or an object used as
19+
an S3 object key / `path.join` segment.
20+
- **Download** branched on `result.url` / `result.redirect` / `result.stream`
21+
/ `result.mimeType` while the contract's `download(key)` resolves a
22+
`Buffer`, so every branch fell through and the route answered a
23+
JSON-serialized Buffer.
24+
25+
Both routes are removed, along with `HttpDispatcher.handleStorage()`, the
26+
`/storage` domain registration, the dispatcher-plugin mounts and the two route
27+
ledger rows.
28+
29+
**Migration.** There is nothing to migrate off in practice — neither route
30+
could complete a request. (They were reachable: `service-storage` mounts
31+
`/storage/upload/presigned`, not `/storage/upload`, so nothing shadowed them.
32+
They simply had no caller — no SDK method builds those URLs.)
33+
`/api/v1/storage` is `@objectstack/service-storage`'s surface and always was
34+
the working one:
35+
36+
- Upload — FROM `POST /api/v1/storage/upload` TO the presigned protocol
37+
(`POST /storage/upload/presigned` → direct `PUT` to the returned URL →
38+
`POST /storage/upload/complete`), or `client.storage.upload(file)`, which
39+
runs all three steps.
40+
- Download — FROM `GET /api/v1/storage/file/:id` TO
41+
`GET /storage/files/:fileId/url` (`client.storage.getDownloadUrl(fileId)`)
42+
for a signed URL, or `GET /storage/files/:fileId` for a stable browser URL
43+
that 302s to it.
44+
45+
Install `@objectstack/service-storage` to get those routes; without it
46+
`/api/v1/storage` now has no handler, which is the same answer every other
47+
uninstalled capability gives.
48+
49+
Two follow-on corrections keep `declared === enforced`:
50+
51+
- `@objectstack/hono` no longer mounts `app.all('<prefix>/storage/*')`. That
52+
wildcard claimed the whole `/storage` subtree for the two dead routes, so
53+
every other path under it — service-storage's protocol above all — got the
54+
bridge's own 404 rather than falling through. Storage is ordinary catch-all
55+
traffic now.
56+
- Discovery keeps gating `routes.storage` on `isServiceServeable` — the shared
57+
`handlerReady` predicate #4058 step 2 introduced — and plugin-dev's in-memory
58+
implementation now self-declares `handlerReady: false`. #4058 deliberately
59+
left that one serving because the `/storage` bridge was still there to serve
60+
it; with the bridge retired nothing routes HTTP to that slot, so `false` is
61+
the honest value — the position `realtime` has held since ADR-0076 D12. The
62+
implementation keeps working for in-process callers; it is simply no longer
63+
advertised as a reachable HTTP capability.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: reject unknown keys on the approval authoring schemas (#4001 step 3)
6+
7+
Third click of the unknown-key strictness ratchet (flow + permission in
8+
#4071, RLS / sharing / position in #4099). Approval is a v17-new authoring
9+
surface — tightened while young, before stored volume exists:
10+
11+
- **`automation/approval.zod.ts`**`ApprovalNodeConfigSchema`,
12+
`ApprovalNodeApproverSchema`, `ApprovalEscalationSchema`, and
13+
`DecisionOutputDefSchema` are `.strict()` with fixable errors. An approval
14+
gate that quietly ignores half its config is the worst instance of the
15+
ADR-0078 trap — the request routes, but not the way the author declared.
16+
- The published JSON schema (`getApprovalNodeConfigJsonSchema`) now carries
17+
`additionalProperties: false` into the Studio property form AND
18+
`registerFlow()`'s per-node config validation (#4027/#4040), so an unknown
19+
key inside an approval node's `config` is rejected at registration too.
20+
21+
**Migration.** Any key now rejected was previously stripped and had no
22+
runtime effect — removing or renaming it never changes behavior. Mappings
23+
baked into the errors include the ADR-0019 re-home map for process-era
24+
concepts: `steps` → successive approval NODES on the canvas, `entryCriteria`
25+
→ the condition on the entering edge, `onApprove` / `onReject` → the nodes
26+
wired to the `approve` / `reject` out-edges, `rejectionBehavior` → a declared
27+
back-edge (ADR-0044) with `maxRevisions`. Plus spelling aliases:
28+
`mode` / `approvalMode``behavior`, `quorum``minApprovals`,
29+
`statusField``approvalStatusField`, `org``organization`,
30+
`expandAs``resolveAs`, `timeout` / `hours` / `sla``timeoutHours`,
31+
`to` / `target``escalateTo`, `name``key`, `widget``type`.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
---
3+
4+
chore(tooling): enumerate namespace-claiming wildcard mounts and require each to be classified (#4116)
5+
6+
Release-nothing: adds `scripts/check-wildcard-fallthrough.mjs` plus its CI step.
7+
No package code changes.
8+
9+
A handler mounted on `<prefix>/*` claims an entire namespace, and Hono runs
10+
handlers matching a path in registration order with the first Response winning.
11+
So a TERMINAL wildcard — one that always answers and never yields — makes every
12+
other route under that prefix reachable only when it happens to register first.
13+
That shape has cost four fixes (#2567, #4018, #4088/#4092, cloud#923) and every
14+
one was found by hand, after the fact, by someone reading the code for an
15+
unrelated reason.
16+
17+
The two driven tests #4092 and cloud#923 shipped each pin one catch-all, and are
18+
structurally unable to cover the next wildcard someone mounts. What never existed
19+
is the enumeration. This scan is it: three states (`yields`, verified from the AST
20+
so the entry cannot rot; `exempt` with a reason; `ratchet` naming the issue), and
21+
a mount the scan finds but the ledger does not declare is an error, never a
22+
default — the same shape as `check-route-envelope.mjs` (#3843).
23+
24+
It found 13 namespace-claiming mounts where a manual grep had found 3, including
25+
two real, previously untracked instances of the #4088 defect in
26+
`packages/adapters/hono` (`${prefix}/auth/*` and `${prefix}/storage/*`), now
27+
ratcheted under #4117.

.github/workflows/lint.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ jobs:
123123
- name: Error-code casing guard
124124
run: pnpm check:error-code-casing
125125

126+
# Namespace-wildcard fall-through guard (#4116). A handler mounted on
127+
# `<prefix>/*` claims the whole namespace, and Hono's first-registered
128+
# handler that answers wins — so a TERMINAL wildcard makes every other
129+
# route under that prefix reachable only by registration luck. That shape
130+
# has now cost four fixes (#2567, #4018, #4088/#4092, cloud#923) and every
131+
# one was found by hand, never by CI. Per-plugin tests cannot cover the
132+
# next one; what was missing is the ENUMERATION. Three states — yields
133+
# (verified from the AST, so it cannot rot) / exempt-with-reason / ratchet
134+
# — and an undeclared mount is an error, never a default. Runs its own
135+
# --self-test first.
136+
- name: Wildcard fall-through guard
137+
run: pnpm check:wildcard-fallthrough
138+
126139
# Release-notes drift guard: the platform is one version-locked train, so
127140
# every released @objectstack/spec major must have a curated, navigable
128141
# release page at content/docs/releases/v<major>.mdx. Catches the gap that

content/docs/api/plugin-endpoints.mdx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,43 @@ protocol `ai.chatStream` parses and owns message state for you.
144144

145145
### File Storage (`/storage`) — Plugin Required
146146

147-
| Method | Endpoint | Description |
148-
|:-------|:---------|:------------|
149-
| POST | `/storage/upload` | Upload a file |
150-
| GET | `/storage/file/:id` | Download a file |
147+
Provided by `@objectstack/service-storage`, which registers these routes on the
148+
host HTTP server. Uploads are a three-step protocol — ask for a presigned
149+
target, send the bytes straight to it, then commit — so the bytes never proxy
150+
through the API server, and the same shape works for S3, local disk, or any
151+
other adapter.
152+
153+
| Method | Endpoint | SDK | Description |
154+
|:-------|:---------|:----|:------------|
155+
| POST | `/storage/upload/presigned` | `storage.getPresignedUrl` | Step 1 — mint an upload target for a new `sys_file` row |
156+
| POST | `/storage/upload/complete` | `storage.upload` | Step 3 — commit the uploaded bytes |
157+
| POST | `/storage/upload/chunked` | `storage.initChunkedUpload` | Open a chunked / resumable session |
158+
| PUT | `/storage/upload/chunked/:uploadId/chunk/:chunkIndex` | `storage.uploadPart` | Send one chunk |
159+
| POST | `/storage/upload/chunked/:uploadId/complete` | `storage.completeChunkedUpload` | Assemble the parts |
160+
| GET | `/storage/upload/chunked/:uploadId/progress` | `storage.resumeUpload` | Read session progress (first step of a resume) |
161+
| GET | `/storage/files/:fileId/url` | `storage.getDownloadUrl` | Resolve a short-lived signed download URL |
162+
| GET | `/storage/files/:fileId` || Stable browser URL; 302s to the same signed URL (what file fields carry) |
163+
164+
`storage.upload(file)` runs steps 1–3 for you, including the direct-to-storage
165+
`PUT` in step 2.
166+
167+
<Callout type="warn">
168+
**This table used to list two routes that never worked (#4087).** `POST
169+
/storage/upload` and `GET /storage/file/:id` were a dispatcher-side bridge to
170+
the `file-storage` service, written against a service shape that does not
171+
exist: it called `upload(key, data, options?)` as `upload(file, { request })`
172+
a `TypeError` against every implementation in the repo — and read the `Buffer`
173+
that `download(key)` resolves as if it were a `{ url | stream | mimeType }`
174+
descriptor. They stayed mounted and reachable — nothing shadowed them — so
175+
anyone who followed this table got a 500; everyone who followed the SDK used
176+
the protocol above and never touched them.
177+
178+
Both routes are retired. `/storage` is service-storage's surface — install that
179+
package to get it. Discovery advertises the route only when the occupant of the
180+
`file-storage` slot actually mounts HTTP handlers, so an in-memory dev
181+
implementation now reports `handlerReady: false` and no `routes.storage`
182+
instead of pointing at a path with nothing behind it.
183+
</Callout>
151184

152185
---
153186

content/docs/data-modeling/drivers.mdx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,20 +353,34 @@ equivalent age-based reap.
353353
## Memory Driver
354354

355355
The in-memory driver keeps records in plain in-process objects (queried via
356-
[`mingo`](https://github.com/kofrasa/mingo)). Data is lost when the process exits.
356+
[`mingo`](https://github.com/kofrasa/mingo)).
357357
It is the **last-resort fallback** in dev mode: `objectstack dev` prefers native
358358
SQLite (`better-sqlite3`), falls back to the pure-JS WASM SQLite driver if the
359359
native binary is unavailable, and only drops to the in-memory driver if WASM also
360360
fails to load. Set `OS_DATABASE_DRIVER=memory` to select it explicitly.
361361

362+
**Whether data survives the process depends on how the driver is built** (#4083):
363+
364+
| How it is built | Persistence |
365+
| :--- | :--- |
366+
| A **declared datasource** — `{ driver: 'memory' }` in a stack/app config | **Ephemeral.** Nothing is written to disk unless the declaration sets `config.persistence`and when it does, the destination is scoped per datasource, so two memory datasources never share one file. |
367+
| `new InMemoryDriver()` **constructed directly** | `persistence: 'auto'`under Node that means a JSON file at `.objectstack/data/memory-driver.json`, relative to the process's working directory, reloaded on the next boot. |
368+
369+
Pass `persistence: false` for a driver you construct yourself and want purely in
370+
memorya test, for instance, which should neither depend on nor leave behind
371+
state in the working directory. Two directly-constructed `'auto'` drivers in one
372+
process still share that single default path.
373+
362374
```typescript
363375
import { InMemoryDriver } from '@objectstack/driver-memory';
364376
365-
new InMemoryDriver();
377+
new InMemoryDriver({ persistence: false }); // pure memory
378+
new InMemoryDriver(); // 'auto' — file-backed under Node
366379
```
367380

368381
<Callout type="tip">
369-
Use the memory driver for unit tests. It requires no setup and runs instantly.
382+
Use the memory driver for unit tests. It requires no setup and runs instantly
383+
with `persistence: false`, so one run cannot see what an earlier run left behind.
370384
</Callout>
371385

372386
## Local Environment Runtime

0 commit comments

Comments
 (0)