Skip to content

Commit f25f6a4

Browse files
committed
Merge origin/main into claude/issue-19975-read-scope-eq-array
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
2 parents 11c11c7 + 276d96d commit f25f6a4

132 files changed

Lines changed: 6050 additions & 4950 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.

‎.changeset/15178-translation-bundle-split-settings-platform-only.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ and the rejection carries the prescription above.
6666

6767
### Unchanged
6868

69-
The registered `translation` metadata type (`TranslationItemSchema`) still
70-
declares `settings` — this ruling covers the file-authored bundle. `GET
69+
The registered `translation` metadata type (`TranslationItemSchema`) is not
70+
changed by THIS entry — this ruling covers the file-authored bundle. (Superseded
71+
in the same release: #19620 narrows the item door too; see its own changeset.) `GET
7172
/api/v1/i18n/translations/:locale` still declares it on its response, because the
7273
served document is the merged tree; `GetTranslationsResponseSchema` is typed
7374
against the platform face for exactly that reason.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(cli): `objectstack serve` mounts the always-on `package-registry` capability, so a package created through the API survives a restart on a stock boot (#19387)
7+
8+
Clause-②: no
9+
10+
`package-registry` has been on the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`) since the `marketplace` / `package-registry` split, and `serve` appended it to every app's `requires`. But `Serve.CAPABILITY_PROVIDERS` did not key it, and the resolver's no-provider branch says nothing about a token the app did not declare itself. So an app that did not declare `requires: ['marketplace']` got no `package` service. `POST /api/v1/packages` answered `201`, printed `no 'package' service — '…' registered in-memory only (will not survive a restart)`, and `GET /api/v1/packages/:id` answered `404` after a restart.
11+
12+
- **`package-registry` now mounts `PackageServicePlugin`** from `@objectstack/service-package`, the provider the spec's `PLATFORM_CAPABILITY_PROVIDERS` row declares for it. A stock boot creates `sys_packages` and replays it at start, so installs and manifest edits made through the API persist.
13+
- **Apps that declare `marketplace` boot as before, with one `PackageServicePlugin`.** `marketplace` resolves to the same provider. The capability resolver now remembers the providers it has mounted itself, so the always-on token does not mount a second copy. Without that change, a declarer's boot would print `Plugin superseded: 'package-service'`.
14+
- **A stock database gains one table, `sys_packages`.** `PackageServicePlugin` creates it with raw DDL, as it already did for `marketplace` declarers. On the in-memory driver (`memory://`), which has no raw SQL, the boot now logs that the DDL was not run and that package hydration was skipped. Packages there last only as long as the process, as before.
15+
- `--preset minimal` still opts out of the whole slate. `protocol.installPackage` keeps its in-memory-only branch as the documented degraded path for hosts that mount no provider.
16+
- **`@objectstack/metadata-protocol`: the `installPackage` docblock no longer says the runtime half is missing.** It used to say that a stock boot still took the in-memory-only branch. It now says that `objectstack serve` mounts `PackageServicePlugin` for `package-registry`, so a stock boot persists, and that the in-memory-only branch is for hosts that mount no provider. The docblock ships in `dist`. No behaviour changes.

‎.changeset/19482-doc-nav-item.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
**Clause-②: yes (widening)** — a new member (`type: 'doc'`) on the published, strict navigation-item union, and a new exported schema (`DocNavItemSchema`), so the accept set an app author writes against grows. Nothing previously admitted is refused: the `docs/nav-target` build rule judges only `doc` items, which no stack could carry before. Contract-review tier.
7+
8+
A documentation entry on the app menu: the new `type: 'doc'` navigation item (`DocNavItemSchema`, ADR-0046) targets a `book` and/or a `doc`, and at least one is required.
9+
10+
```ts
11+
{ id: 'nav_help', type: 'doc', label: 'Help Centre', book: 'crm_manual' } // opens the book
12+
{ id: 'nav_guide', type: 'doc', doc: 'crm_lead_guide' } // opens that page
13+
{ id: 'nav_both', type: 'doc', book: 'crm_manual', doc: 'crm_lead_guide' } // that page, in that book
14+
```
15+
16+
- **`book` alone** opens the book at its first readable page with the book sidebar. Membership is derived by the book's group rules, so a doc added later that matches a rule appears under the entry with no navigation edit. The package id also names a book — the package's implicit book.
17+
- **`doc` alone** opens that page; its book context is the doc's own book, else the package's implicit book. `doc` is a doc NAME (the source filename stem, lowercase snake_case): `crm_lead_guide.md` or `docs/crm_lead_guide` is refused.
18+
- **Neither** is refused when the app is parsed, with a message naming both keys. The rule also reaches the published JSON Schema (`json-schema/ui/DocNavItem.json`) as an `anyOf` of `required`, so a validator reading the schema refuses the same shape.
19+
- **Audience**: the entry has no gate of its own — it inherits the docs audience gate. A `book` entry shows the member only the pages they may read, and is not shown to a member who may read none; a `doc` entry the member may not read is not shown. `visible` / `requiredPermissions` can only narrow that further.
20+
- **`os build` / `os validate` / `os lint`** refuse a `doc` entry whose `book` or `doc` names nothing in the package (new rule `docs/nav-target`, with a did-you-mean). This runs in the docs step because that is where docs from `src/docs/*.md` join the artifact. It checks app `navigation`, `areas[].navigation` and `manifest.navigationContributions`.
21+
- Near-misses are answered: `docName` → `doc`, `bookName` → `book`, and `book` / `doc` written on another item type points at `type: 'doc'`.
22+
23+
The console renders the new entry in a later objectui release; until then a `doc` item parses and publishes, but the menu does not show it.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
---
5+
6+
**BREAKING for runtime-authored `translation` items** — the registered `translation` metadata type no longer declares `settings`: platform settings copy is platform-only at BOTH application doors (#19620)
7+
8+
Clause-②: no
9+
10+
`TranslationItemSchema` — one `translation` metadata item, authored with
11+
`defineTranslation`, in Studio, or through the metadata API — now takes the same
12+
ten groups as a per-app bundle entry (`TranslationData`). `settings`, and its
13+
singular `setting`, are refused by name with the platform-only prescription,
14+
exactly as the per-app bundle has refused them since #15178. The file door and
15+
the item door are two authoring surfaces for one app metadata type, so they
16+
accept one shape.
17+
18+
### Migration — FROM → TO
19+
20+
| You wrote | Write instead |
21+
| --- | --- |
22+
| `defineTranslation({ locale: 'zh-CN', settings: { mail: { title: '邮件投递' } } })` | delete the `settings` group — there is no application-side replacement key |
23+
| a `translation` item saved through the metadata API or Studio carrying `settings` | delete the `settings` group; the save answers `422 INVALID_METADATA` until you do |
24+
| `const t: TranslationItem = { locale: 'en', settings: … }` | move the copy to the PLATFORM bundle (`PlatformTranslationData`), or delete it |
25+
26+
**The one-line fix: delete the `settings` group from the item.** Settings copy is
27+
not application-authorable — `settings` is keyed by `SettingsManifest.namespace`
28+
and only platform code declares a manifest. `settingsCommon` is **not** affected:
29+
the Settings UI shell strings (the source badges, under
30+
`settingsCommon.sourceLabels`) stay on both application faces.
31+
Run `os migrate meta --from 17` to list the mechanical edits for existing
32+
sources; apply them by hand.
33+
34+
### Rows already stored are converted, not refused
35+
36+
A `translation` row saved before this change keeps loading. The runtime
37+
translation sync (`@objectstack/core`'s `authored-translation-sync`) reads
38+
`sys_metadata` itself and used to merge the RAW stored payload; it now replays
39+
the ADR-0087 conversion chain over each row before merging it, the same policy
40+
as every other stored-metadata read seam. `translation-per-app-settings-removed`
41+
has learned the item shape, so a stored row's `settings` is dropped there, the
42+
rest of the item (`objects`, `apps`, …) still loads, and the server logs one
43+
warning per row naming the row, the group and the conversion. Run
44+
`os migrate meta --stored --apply` to persist the canonical rows.
45+
46+
### What changes on screen, which is not nothing
47+
48+
On the item door the group was STRONGER than on the bundle door. A published
49+
item is loaded into the runtime-authored layer, which both i18n adapters read
50+
**over** the shipped bundles — so an item's `settings` overrode the platform's
51+
own Settings copy for its locale, rather than only filling gaps. After
52+
upgrading, re-read the Settings screens in each locale such an item covered:
53+
where it overrode a platform string, **the platform's string renders again**;
54+
where it filled a gap the platform bundle leaves, the **manifest's own literal
55+
renders, which is English**. If a platform string is wrong or missing for your
56+
locale, correct it in the platform bundle (`@objectstack/service-settings`'s
57+
`settingsBuiltinTranslations`).
58+
59+
No deprecation window: the item door refuses the key by name from this major.
60+
61+
### Unchanged
62+
63+
The platform face — `PlatformTranslationDataSchema`, `settingsBuiltinTranslations`,
64+
and `GET /api/v1/i18n/translations/:locale`, whose served document is the merged
65+
tree — still declares `settings`. The liveness ledger's `translation.settings`
66+
row is deleted because the key left the ITEM's shape; the platform capability it
67+
evidenced is untouched.
68+
69+
Ruling batch #210 item 2 letter B (2026-09-22) — maintainer 「210 同意」.
70+
71+
<!-- adr-0087: not-required (already-registered translation-per-app-settings-removed, translation-per-app-settings-platform-only) both entries already existed for the per-app bundle door; this change EXTENDS them to the translation item door in the same unreleased major — the D2 conversion learns the bare item shape and the D3 semantic entry covers both doors -->
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
---
4+
5+
fix(driver-turso)!: a local or replica `TursoDriver` on a remote url, or a replica off a local file, is refused at construction
6+
7+
Clause-②: no (narrowing)
8+
9+
A remote `url` beside `syncUrl` was classified as an embedded replica, and the local SQLite engine that every replica read and write goes through was handed `:memory:`. Writes succeeded and read back, then vanished on restart, and none of them reached the remote. `@libsql/client` builds no embedded replica for a remote url: it routes `libsql://` / `https://` / `http://` to its HTTP client and `wss://` / `ws://` to its WebSocket client, neither of which reads `syncUrl`. A forced `mode: 'replica'` or `mode: 'local'` beside a remote url was handed the same `:memory:` engine, and so was a replica on `:memory:`. Measured before the change, with `create`, `find`, then a fresh driver on the same config:
10+
11+
```
12+
libsql:// + syncUrl (sync.onConnect: false) -> 1 row back, 0 rows after restart
13+
libsql:// + mode: 'replica' or mode: 'local' -> 1 row back, 0 rows after restart
14+
:memory: + syncUrl + a supplied client -> 1 row back, 0 rows after restart
15+
file: + syncUrl (unchanged) -> 1 row back, 1 row after restart
16+
```
17+
18+
With the driver building its own client and the default `sync.onConnect`, two of these did fail at `connect()`, but on a libsql error that did not say why: `libsql://` + `syncUrl` with `SYNC_NOT_SUPPORTED`, and `:memory:` + `syncUrl` with `URL_INVALID`.
19+
20+
**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`. A remote url here means one of the lowercase schemes `TursoDriver.detectMode` classifies as remote: `libsql://`, `https://`, `http://`, `wss://`, `ws://`. Refused:
21+
22+
- a remote url beside `syncUrl`;
23+
- a remote url under a forced `mode: 'replica'` or `mode: 'local'`;
24+
- a replica on a url `@libsql/client` reads as in-memory (`:memory:`, or `file::memory:` with or without a query string), beside `syncUrl` or under a forced `mode: 'replica'`. `@libsql/client` refuses such an embedded replica itself. For `:memory:` and a bare `file::memory:` the local engine was a private in-memory database. With a query string it was a file literally named after the url's path (for example `:memory:?cache=shared`) in the working directory, which no sync reaches;
25+
- under a forced `mode: 'replica'` only, any `url` that is not a local `file:` path, such as a bare path or an uppercase scheme.
26+
27+
The remote-url refusal names the scheme it met. Neither refusal echoes the url, which may carry a token. Both loaders (`@objectstack/runtime`'s host factory and the datasource factory) reach this refusal through the same constructor, so a datasource declaring one of these configurations now fails by name when its loader builds the driver.
28+
29+
**What stays accepted**, pinned by preservation tests: a `file:` url with `syncUrl` (the embedded replica), a `file:` or `:memory:` local database, a remote url on its own or with `mode: 'remote'`. `TursoDriver.detectMode()` still classifies a remote url beside `syncUrl` as `'replica'`: the refusal sits in the constructor, not in a re-classification.
30+
31+
**Not refused, unchanged here:** a url with no `mode` that is none of `file:`, `:memory:` or a lowercase remote scheme, such as an uppercase `LIBSQL://` or a bare path like `./data/app.db`, still auto-detects `'local'` and still runs on `:memory:`, with or without `syncUrl`. So does the same url under a forced `mode: 'local'`. That fall-through is tracked as #19976.
32+
33+
**What an affected author does.** Each refusal names its ways out. For a remote url in a local or replica mode:
34+
35+
- to use the remote database, drop `syncUrl` (and `sync`) and any forced `mode`; the remote url alone sends every read and write to it;
36+
- for an embedded replica, point `url` at a local file and keep the remote in `syncUrl`: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'`.
37+
38+
For a replica off a local file, point `url` at a local `file:` path beside `syncUrl`. A throwaway in-memory database instead drops `syncUrl` (and `sync`) and any forced `mode: 'replica'`, and keeps `url: ':memory:'`.
39+
40+
Blast radius, measured on this tree: no example, template, published skill, hand-written doc or factory default declares a remote url beside `syncUrl`, and the host boot path (`OS_DATABASE_URL`) passes no `syncUrl`. Outside this package's own tests, the in-repo configurations carrying the pair are test fixtures that never construct the real driver: loader fixtures that exercise the config builder or a capturing constructor, stored-row redaction fixtures and a schema-parse fixture. Whether any out-of-repo deployment declares it is NOT measured and is not claimed to be zero.
41+
42+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed at the driver constructor: no key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `TursoDriverConfig.url`, `syncUrl` and `mode` keep their names and types, and both `TursoConfigSchema` copies are untouched. What moves is which CONFIGURATIONS `new TursoDriver()` accepts, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. Each refusal names its ways out, and which one an author wants (a remote database, an embedded replica on a local file, or a plain local database) is authoring intent no ledger line can decide. -->

‎.changeset/19911-readonlywhen-interdependent-locks.md‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ affected too (a `readonlyWhen` lock binds them).
1919

2020
**What happens now.** A value one `readonlyWhen` lock drops can no longer
2121
unlock another: no field is written while its `readonlyWhen` is TRUE on the row
22-
the update stores. The locks are judged together, again with each dropped
23-
value put back to the row's stored one, until no further field locks. Then the
24-
fields held only by a value that was later put back are released, and every
25-
lock is judged again against what that release stores, round after round,
26-
until the dropped fields are exactly the ones locked on the row the update
27-
stores; after one round more than the caller sent fields carrying a
28-
`readonlyWhen`, the first, larger set of drops stands instead. In the example
22+
the update stores. A lock is judged after the locks whose fields it reads, with
23+
each value they drop put back to the row's stored one. Locks that read each
24+
other in a cycle are judged together, again with each dropped value put back,
25+
until no further field locks. Then the fields held only by a value that was
26+
later put back are released, and every lock in the cycle is judged again
27+
against what that release stores, round after round, until the dropped fields
28+
are exactly the ones locked on the row the update stores; after one round more
29+
than the cycle has fields, the first, larger set of drops stands for the
30+
cycle's fields instead. In the example
2931
above `amount` is dropped as locked, exactly as `update(c1, { amount: 999 })`
3032
on its own always was. Values a `beforeUpdate` hook wrote are still stored and
3133
read as before.
@@ -44,20 +46,22 @@ read as before.
4446
no longer refuses the write, and clearing a field the kept amount requires is
4547
now refused (`VALIDATION_FAILED`).
4648
- A field whose own lock is FALSE on the stored row can still be dropped, but
47-
only where locks read each other in a cycle (a `parent`-scoped lock counts
48-
as reading the master-detail field, which picks the header). Without such a
49-
cycle the update drops exactly the caller's fields whose lock is TRUE on the
50-
row it stores (on a bulk update, on at least one matched row). In a cycle,
49+
only when its lock is in a cycle of locks that read each other's fields (a
50+
`parent`-scoped lock counts as reading the master-detail field, which picks
51+
the header, and a lock that reads `record` other than as `record.<field>`
52+
counts as reading every field). A field whose lock is in no such cycle is
53+
dropped exactly when its lock is TRUE on the row the update stores (on a
54+
bulk update, on at least one matched row). In a cycle,
5155
no set of drops may agree with the stored row: with `a` locked by `record.b
5256
== 'x'` and `b` by `record.a == 'old_a'`, `update(r, { a: 'new_a', b: 'x'
5357
})` on a row `{ a: 'old_a', b: 'y' }` drops both, although `a` is unlocked
5458
on the row it stores. A cycle can also have more than one set that agrees:
5559
with `a` locked by `record.b == 'new_b'` and `b` by `record.a == 'new_a'`,
5660
`update(r, { a: 'new_a', b: 'new_b' })` on a row holding neither new value
5761
would agree with the row by dropping either one, and it drops both. Some
58-
other updates with two such sets store one of them. Where the drops do not
59-
settle, the first, larger set stands: a lock the update cannot settle is
60-
not waived.
62+
other updates with two such sets store one of them. Where a cycle's drops do
63+
not settle, the first, larger set stands for that cycle's fields: a lock the
64+
update cannot settle is not waived.
6165
- A master-detail repoint that the field's own `record`-scoped lock used to
6266
hold can now land. Its lock is judged together with the other locks on the
6367
header the update names, so when the value that lock reads is itself locked

0 commit comments

Comments
 (0)