Skip to content

Commit 8a6c373

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-1wujme
2 parents 7e2469f + 771b2db commit 8a6c373

48 files changed

Lines changed: 2909 additions & 334 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+
'@objectstack/core': patch
3+
'@objectstack/service-messaging': patch
4+
'@objectstack/service-queue': patch
5+
---
6+
7+
fix(core,service-messaging,service-queue): an idle dispatch loop wakes at the next due time, and a sole writer's loop no longer re-runs the idle ramp after every boot
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **`@objectstack/core`: `DispatchLoop` wakes at the next due time.** A new optional `DispatchLoopOptions.nextDueAt()` answers the earliest instant work becomes claimable (a deferred row, a claim's expiry). The loop asks it once per idle streak, after the first empty tick, and never sleeps past the answer, so a deferred row goes out within one tick of coming due whatever `maxIdleIntervalMs` is. The answer is remembered rather than re-read on every ceiling tick, so an idle loop's steady-state cost is unchanged; the new `DispatchLoop.wakeAt(dueAtMs)` lets a producer in the same process announce a deferred row at no read. An answer at or before the tick that found nothing is not chased, and a rejected tick or read still counts as idle, so neither can make the loop spin.
13+
- **`@objectstack/core`: `DispatchLoopOptions.soleWriter`** (default `false`). On a host where no other process writes the store, an empty tick sleeps straight to the next due time or the ceiling instead of re-running the ramp from `intervalMs`, which only ever bought latency on rows another process wrote. A fresh sole-writer loop over an empty store now ticks once in its first minute; before, every boot re-ran the ramp (six ticks in the first minute at the dispatchers' 500 ms base, five at the queue's 1 s).
14+
- **`@objectstack/service-messaging`:** `INotificationOutbox.nextDueAt` / `IHttpOutbox.nextDueAt` (optional; both built-in stores implement it: two reads, the earliest future `next_attempt_at` / `next_retry_at` and the oldest `in_flight` claim plus the claim TTL), wired into `NotificationDispatcher` and `HttpDispatcher`; `soleWriter` on both dispatchers' options, and `dispatchSoleWriter` on `MessagingServicePlugin`. Retries, quiet hours, digest windows and a crashed node's claims are taken at their due time instead of up to `dispatchMaxIdleIntervalMs` late.
15+
- **`@objectstack/service-queue`:** `DbQueueAdapter` reads the earliest future `scheduled_for` of its subscribed queues (one read per idle streak), a deferred `publish()` announces its due time to the loop through `wakeAt` instead of waiting out the backoff, and `DbQueueAdapterOptions.soleWriter` (reachable as `QueueServicePlugin` `db.soleWriter`) skips the ramp.
16+
17+
Nothing changes for a host that sets neither `soleWriter` flag except the due-time wake: the ramp keeps its shape, cut short only when a due row is earlier, and an idle stretch costs one extra read at its start.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
feat(app-shell): Studio customizes an installed package's page into the base's customization package (#30063)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
On a read-only (installed or code-shipped) package, a page opened in the Interfaces pillar now offers Customize, the same button a dashboard, report or view shows. Before, Studio showed the page as read-only with no way to change it, although the server already serves a page override from the paired customization package (`<base>.custom`).
11+
12+
- Customize unlocks the page designer. Nothing is written until an edit is saved. The first saved edit copies the whole page into the pair, declaring `overrides: { package: <base>, basedOn: <base manifest version> }`. Later edits update that copy. The base package is never written.
13+
- The edit is a draft in the pair's pending changes; once published, the running app serves the customized page at its usual address.
14+
- The page's header carries the override mark, whose popover offers "Restore package original": it deletes the pair's draft and copy of the page after a confirmation, and the shipped page reads back.
15+
- The Interfaces rail marks a customized page, "only customizations" counts and lists it, and the All metadata directory's page tile counts it.
16+
17+
A customized page is a whole copy: a change the vendor ships to that page in a later version does not appear until the customization is restored and redone. A writable package's page editing is unchanged. No spec, server or metadata-type change.
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/service-settings': patch
4+
---
5+
6+
fix(service-settings): when two data-key files hold different keys, the boot judges the newest stored secrets as a page, not only the newest one, and reports a database split between the two keys
7+
8+
Clause-②: no — an internal boot heuristic and its notice text. The shapes it changes (`StoredSecretReader` now answers the page, `KeyFileChoice` gains the `mixed` reason and a `tally`, `DataKeyOpensNothingError` gains `judged`) were added by #30042, whose note is still pending, so they first ship together in this release; no released export changes.
9+
Metadata-protocol: none
10+
11+
- **`@objectstack/service-settings`.** When the shared key file and the project's own key file hold different keys, the boot now trial-opens each of the newest stored `sys_secret` rows (up to ten) under both keys and reads with the key that opens more of them, on a tie the one that opens the newest. A row that opens under neither key (a corrupt or foreign row) no longer decides the boot: if an older row opens under one key, that key is used, and only a page where no row opens under either key keeps the production refusal. When each key opens some of the rows, the database holds secrets under both keys: the boot uses the key that opens more of them and prints an error naming both files, how many rows each key opened, and the fix. The boot's notices now say what was measured ("opens the 3 newest stored secrets", "opens 2 of the 3 newest stored secrets") instead of claiming the chosen file opens all of the database's stored secrets.
12+
- **`@objectstack/cli`.** The one-shot commands (`os migrate`, `os secret rewrap`, `os secret orphans`, `os storage orphans`) share the same trial, so they make the same choice.
13+
14+
**The remedy for a split database.** Keep a copy of both files, then save again each secret that reads as unset: it is sealed under the key the boot uses. Once nothing reads as unset, put that key at the shared file (or set `OS_SECRET_KEY` to it).
15+
16+
Supersedes, in the same release: the note for #30042 says the boot opens the newest stored secret under each key and quotes the refusal as "neither data key opens this database's stored secrets". After this change the boot judges the newest rows as a page, and the refusal reads "neither data key opens any of the N newest stored secrets".
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
feat(i18n): one label order on an overridden item, for all eight document translators — the pair's translation, then the override's own label, then the base's translation
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
- **New option `TranslateDocumentOptions.pairBundle`.** The translations the served item's paired `.custom` package ships itself (ADR-0146 D2/D3), locale-keyed as `bundle` is. The serving layer's `bundle` is one merged catalog per locale with the package ids dropped, so only this option can tell a translation the pair ships from the base's.
11+
- **The order, per translatable string.** A string the override changed (`valueOverridesPackagedBase` against `packagedBase`, the comparison every translator already makes) reads the pair's translations only, along the locale chain, and otherwise keeps its own text: the base's translation is never read for it. A string the override left alone reads, locale by locale, the pair's translation and then the deployment catalog, so a pair that ships only `en` never displaces the base's `zh-CN` on an untouched label. Nested labels follow the same rule: widget titles, filter and option labels, view bulk-action defs and form sections, report blocks, field labels, help and options, page component copy, tab items and the header, dataset members, app navigation nodes and book groups.
12+
- **`translatePage`, `translateDataset`, `translateApp` and `translateBook` now read `packagedBase`** (their third parameter widens from `ResolveOptions` to `TranslateDocumentOptions`), each string judged against the base's counterpart: the page's components by id and its header, the dataset's members by `name`, the app's navigation nodes by `id`, the book's groups by `key`. A page header title the override left as the base wrote it translates as the base's label.
13+
- **Nothing moves for an existing caller.** With no `pairBundle` (or one that answers none of the chain's locales) every translator gives exactly the answer it gave before, and with no `packagedBase` nothing counts as changed. Passing both for a `.custom` override is the serving layer's half (#30062).
14+
- An object's `validations[].message` and its inline `actions` keep reading the merged catalog: the rule evaluator answers the same message from `i18n.t()` with no pair input, and the refusal text must equal the served text.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security)!: a write from a caller the read door refuses is refused, whatever rows it addresses
6+
7+
Clause-②: no (narrowing) — a predicate update or delete (`multi: true`, addressed by a `where`) from a caller whose read of the object is refused is refused with that read refusal before the write runs, where it was admitted before; nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) no metadata an author writes changes and no stored row is rewritten: a runtime write from a caller the object's read refuses is refused -->
11+
12+
**BREAKING** for a caller whose read of an object is refused while its write is admitted, and that updates or deletes that object's records by predicate. With the permission model's own grants that caller exists in one shape only: an object whose `requiredPermissions` map gates `read` and not `update` or `delete` (ADR-0066 ⑤), and a caller holding the write grant without the capability the read requires.
13+
14+
- **The rule.** A caller the read door refuses writes none of the object's records, by id or by predicate. The predicate update and delete such a caller addresses — a flow's `update_record` or `delete_record` node with `multi: true` run as that user, or an engine `update` / `delete` with `multi: true` under that caller's context — now answer the read door's own refusal (the `PERMISSION_DENIED` envelope that caller's read gets), and nothing is written. The by-id update and delete are refused too.
15+
- **Unchanged.** A caller who reads the object keeps its predicate write, scoped to the records it reads. A caller the object's CRUD grants refuse is refused as before. A write the platform issues under the caller's context (a hook's or a cascade's own write, the referential clear of a deleted record's lookups) and a system write keep their answers. A store fault on the read propagates as before.
16+
- **Who moves.** A deployment that relies on such a caller writing by predicate grants it the capability the object's read requires, or gates the write verbs on a capability that caller holds.

‎content/docs/automation/webhooks.mdx‎

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -452,16 +452,39 @@ same partition — useful for in-order delivery and connection reuse. On a
452452
single-node runtime the lock is an always-grant stub.
453453

454454
Each tick opens with **one** visibility-timeout reap for the whole table —
455-
`in_flight` rows claimed longer than `claimTtlMs` ago return to `pending` — and
456-
then claims partition by partition, so a tick over an empty outbox costs
457-
`1 + partitionCount` statements. The loop **backs off while idle**: every tick
458-
that claims nothing doubles the delay to the next, from `intervalMs` (default
459-
500 ms) up to `maxIdleIntervalMs` (default 30 s; `dispatchMaxIdleIntervalMs` on
460-
`MessagingServicePlugin`), and a tick that claims work snaps it back.
461-
`messaging.enqueueHttp()` and `messaging.redeliverHttp()` wake the dispatcher
462-
running in the same process, so a delivery written there goes out at once. Work
463-
nobody announces — a retry coming due, a row another process wrote — is noticed
464-
within one backed-off interval, never more than `maxIdleIntervalMs` late.
455+
`in_flight` rows claimed longer than `claimTtlMs` ago return to `pending` — then
456+
asks once, across every partition, whether any `pending` row is due. Only when
457+
one is does it claim partition by partition. A tick over an empty outbox
458+
therefore costs **two** statements, both reads, whatever `partitionCount` is:
459+
the reap's "anything expired?" read (its UPDATE runs only when that finds a
460+
row) and the "anything due?" read.
461+
462+
The loop **backs off while idle**: every tick that claims nothing doubles the
463+
delay to the next, from `intervalMs` (default 500 ms) up to `maxIdleIntervalMs`
464+
(default 30 s; `dispatchMaxIdleIntervalMs` on `MessagingServicePlugin`), and a
465+
tick that claims work snaps it back. `messaging.enqueueHttp()` and
466+
`messaging.redeliverHttp()` wake the dispatcher running in the same process, so
467+
a delivery written there goes out at once.
468+
469+
The loop also **wakes at the next due time**. After the first empty tick of an
470+
idle stretch it reads, once, the earliest instant a row becomes claimable
471+
without anyone writing: the soonest future `next_retry_at`, or the moment the
472+
oldest `in_flight` claim passes `claimTtlMs`. It never sleeps past that
473+
instant, so a retry is attempted when it comes due and a crashed node's claim is
474+
recovered when its TTL passes, whatever the ceiling. That costs two more reads
475+
at the start of each idle stretch, not one per tick. The backoff is left with
476+
one job: a row **another process** wrote, which is noticed within one
477+
backed-off interval, never more than `maxIdleIntervalMs` late.
478+
479+
On a host where no other process writes these tables, that job is empty. Set
480+
`dispatchSoleWriter: true` on `MessagingServicePlugin` there, for example on a
481+
hosted tenant kernel with one kernel per tenant database. An empty tick then
482+
sleeps straight to the next due time or the ceiling, instead of re-running the
483+
ramp from `intervalMs` after every boot and every burst, and a fresh kernel's
484+
dispatchers tick once in their first minute. The job queue takes the same flag
485+
as `DbQueueAdapter`'s `soleWriter`, set through `QueueServicePlugin`'s
486+
`db.soleWriter`. Leave both off when a second node or a worker process writes
487+
the same database. Default: off.
465488

466489
Within a held partition, the lock-holder claims a batch with an **atomic
467490
conditional UPDATE** rather than `SELECT … FOR UPDATE SKIP LOCKED`:
@@ -697,7 +720,7 @@ A precise table of what the runtime promises and what it does not.
697720
|------------------------------------------|-------------------------------------------------------------|
698721
| Producer node crashes mid-emit | **Not durable today.** The realtime bus (`InMemoryRealtimeAdapter`) is an unpersisted, in-process pub/sub — an event lost before Stage 3's INSERT is gone, not redelivered (see §4.1). |
699722
| Subscriber node crashes after persist | Row exists in `sys_http_delivery`, another node picks it up. |
700-
| Dispatcher node crashes mid-HTTP | Row stays `in_flight` with `claimed_by`; the first dispatcher tick after the claim TTL reverts it to `pending` and it is re-posted. The TTL derives from the dispatcher tick (`intervalMs`, default 500ms): `lockTtlMs = 5 × intervalMs`, `claimTtlMs = 2 × lockTtlMs` (so ~5s at defaults), all configurable via `HttpDispatcherOptions`. An idle surviving dispatcher ticks at least every `maxIdleIntervalMs` (default 30s), so recovery takes at most `claimTtlMs + maxIdleIntervalMs` (~35s at defaults). |
723+
| Dispatcher node crashes mid-HTTP | Row stays `in_flight` with `claimed_by`; the first dispatcher tick after the claim TTL reverts it to `pending` and it is re-posted. The TTL derives from the dispatcher tick (`intervalMs`, default 500ms): `lockTtlMs = 5 × intervalMs`, `claimTtlMs = 2 × lockTtlMs` (so ~5s at defaults), all configurable via `HttpDispatcherOptions`. An idle surviving dispatcher wakes when the oldest claim it read at the start of its idle stretch passes its TTL, and ticks at least every `maxIdleIntervalMs` (default 30s) in any case, so recovery takes about `claimTtlMs` for a claim it saw and at most `claimTtlMs + maxIdleIntervalMs` (~35s at defaults) for one made after that read. |
701724
| A send outlasts the claim TTL and another dispatcher re-claims the row | The re-claiming dispatcher posts the delivery again (at-least-once — the receiver can see it twice; see the last row). The first dispatcher's late outcome is **not** written over the live attempt: its ack carries the claim credential (`claimed_by`, `claimed_at`) its claim stamped, the conditional update matches nothing, and it logs `http-dispatcher: ack refused, claim no longer held`. The row records the outcome of the attempt that still holds it. |
702725
| Receiver returns 5xx | Retry per backoff schedule until the fixed 8-attempt budget is exhausted (§4.5). |
703726
| Receiver returns 4xx | Treated as terminal — no retry, status `dead` immediately. Exception: 408 / 429 are retried. |

‎content/docs/deployment/backup-restore.mdx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ Escrow the key in your secret manager the day you generate it, and treat
2929
key + database as one backup unit.
3030
</Callout>
3131

32+
**Two key files on one host.** Without `OS_SECRET_KEY` (or `OS_DEV_CRYPTO_KEY`),
33+
the data key comes from a `dev-crypto-key` file: the canonical one under the
34+
ObjectStack home dir (`OS_HOME`, else `~/.objectstack`), which `os dev` and
35+
`os start` share, or a project's own `<project>/.objectstack/dev-crypto-key`,
36+
left by an earlier `os start`, when that is the only file holding a key. When
37+
both files exist and hold **different** keys, the boot trial-opens the newest
38+
stored secrets (up to ten rows) under each key once the database is open, uses
39+
the file whose key opens more of them, and prints which file it chose and how
40+
many rows each key opened. If each key opens some of the rows, the database
41+
holds secrets under both keys: the boot prints an error naming both files, and
42+
the secrets under the unused key read as unset until they are saved again. If
43+
neither key opens any of them, a production boot refuses to start and names
44+
both files. To converge on one key, keep a copy of both files, save again any
45+
secret that reads as unset, then put the key that opens them at the canonical
46+
path (or set `OS_SECRET_KEY` to it) and back that one key up with the database.
47+
3248
## Backing up the database
3349

3450
Use the native tooling of whatever `OS_DATABASE_URL` points at — ObjectStack

‎content/docs/permissions/authorization.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,10 @@ The complete, prioritized gap map lives in issue **#2561** (the production
632632
object's `requiredPermissions` may be a `string[]` (gates all CRUD) or a
633633
`{ read, create, update, delete }` map (read-open / write-gated), enforced
634634
per operation by plugin-security (`security-plugin.ts` capability AND-gate).
635+
A map that gates `read` and not `update` or `delete` does not let a caller
636+
without the read capability change records it cannot read: an update or
637+
delete from a caller the read door refuses is refused, by id or by
638+
predicate.
635639
- **Secure-by-default rollout** (ADR-0066 ④) — **system-object slice landed**:
636640
the raw secret/credential stores (`sys_secret`, `sys_jwks`,
637641
`sys_verification`, `sys_oauth_access_token`, `sys_oauth_refresh_token`,

0 commit comments

Comments
 (0)