Skip to content

Commit 631038b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14787-user-writable-locale
2 parents 592d391 + b1d49b3 commit 631038b

132 files changed

Lines changed: 9264 additions & 1118 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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
feat(spec): retire the ten inert `RestServerConfig` keys the liveness ledger recorded as `dead` (#14691, ADR-0049 enforce-or-remove)
7+
8+
<!-- adr-0087: registered rest-server-config-dead-keys-retired -->
9+
10+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
11+
launch-window convention ships it as `minor`; the prescription is registered
12+
under protocol major 18, where `os migrate meta` users will look).
13+
14+
#14369 enrolled the four `RestServerConfig` sub-objects (`crud`, `metadata`,
15+
`batch`, `routes`) in the spec liveness ledger and found 15 of their 32 rows
16+
`dead`: parsed, defaulted and normalized into the REST server's config by
17+
`normalizeConfig` (#11984) and never read back. This change is the
18+
enforce-or-remove call on every one of them, taken per family, and every
19+
family resolved to REMOVE — each promised capability either already exists at
20+
its proper seat or would contradict a fixed contract. The closed-set cloud
21+
sweep (#14796, `objectstack-ai/cloud` @ `9b6abe0f2fd5`) returned zero hits,
22+
structurally: cloud never authors a `RestServerConfig`.
23+
24+
**What is refused:** authoring any of the ten keys below. Each is a
25+
`retiredKey()` tombstone (all four sub-schemas are non-strict `z.object()`s,
26+
so a plain deletion would have silently stripped the key), so authoring it is
27+
a `tsc` error and a parse error carrying the prescription — and, because
28+
`RestServer` parses these sub-objects at construction (#11984),
29+
`new RestServer(...)` / `createRestApiPlugin().start()` now refuse a config
30+
that carries one, naming the sub-object, the key and the declaring schema.
31+
32+
**FROM → TO** (delete the key in every case; none ever had an effect to preserve):
33+
34+
- `crud.patterns` → the mounted CRUD paths are the contract the client SDK, the
35+
discovery document and the served `/openapi.json` all describe; `crud.dataPrefix`
36+
moves them deployment-wide. An endpoint on a custom path or method is a
37+
declarative `api` endpoint (`type: 'object_operation'`). Its value schema
38+
`CrudEndpointPatternSchema` / type `CrudEndpointPattern` are removed with it
39+
(no other consumer; `CrudOperation` stays — `GeneratedEndpoint.operation` reads it).
40+
- `crud.objectParamStyle` → the object name is always a path segment.
41+
- `metadata.cacheTtl` → `metadata.enableCache` is the live switch (it selects the
42+
protocol's `getMetaItemCached` path, which takes no TTL); a declarative `api`
43+
endpoint's `cacheTtl` is the key that reaches the wire. The unbounded negative
44+
TTL this key accepted goes with it.
45+
- `metadata.endpoints.schema` → gated `GET /meta/:type/:name/schema`, which does
46+
not exist; `endpoints.types` / `items` / `item` gate real mounts and stay.
47+
- `batch.operations.upsertMany` → gated `POST /data/:object/upsertMany`, which
48+
was never built; upsert is an operation type of the generic
49+
`POST /data/:object/batch` endpoint (`BatchOperationType` `'upsert'`), gated
50+
by `batch.enableBatchEndpoint`.
51+
- `batch.defaultAtomic` → atomicity is the per-request `options.atomic`
52+
(ADR-0119 D4, opt-in); a server-side default that flipped it silently would
53+
change the failure semantics of callers who send nothing, which that ADR
54+
refused. Callers that need all-or-nothing send `options: { atomic: true }`.
55+
- `routes.includeObjects` / `routes.excludeObjects` / `routes.overrides`
56+
(`enabled` / `basePath` / `operations`) → per-object API exposure is declared
57+
ON the object and enforced by the REST data surface: `enable.apiEnabled: false`
58+
hides it (404), `enable.apiMethods` whitelists its operations (405). The data
59+
base path is deployment-wide (`crud.dataPrefix`).
60+
- `routes.nameTransform` → the object `name` is the canonical id on every
61+
surface, the REST path segment included; there is no URL transform to configure.
62+
63+
**What stays, byte-identical:** every live key of the four sub-objects —
64+
`crud.operations.*`, `crud.dataPrefix`, `metadata.prefix` / `enableCache` /
65+
`maskObjectFields` / `endpoints.types|items|item`, `batch.maxBatchSize` /
66+
`enableBatchEndpoint` / `operations.createMany|updateMany|deleteMany` — with
67+
its default and its mount. The mounted REST surface does not change: none of
68+
the ten keys ever reached it. `@objectstack/rest`'s normalized config no longer
69+
carries the retired keys (they were written and never read), and the #11984
70+
pins of their accept/reject behaviour are reversed to refusal pins, by design.
71+
72+
The retirement kit: `retiredKey()` tombstones on the four sub-schemas;
73+
`RestServerConfigSchema`'s `@example` no longer advertises
74+
`routes: { excludeObjects: [...] }`; ledger rows kept `dead` with a REMOVED
75+
note and `evidenceScope: cross-repo` (the two container rows collapse into one
76+
each, since their child keys left the walked shape); `RETIRED_KEYS_BY_MAJOR[18]`
77+
× 10 and `RETIRED_DEFS_BY_MAJOR[18]` `api/CrudEndpointPattern`; D3 semantic
78+
entry `rest-server-config-dead-keys-retired`. No D2 conversion: a
79+
`RestServerConfig` is plugin TS configuration (REST plugin constructor /
80+
`plugin-hono-server` `restConfig`), never a stack collection member or a
81+
`sys_metadata` row (the `openApi31` precedent, #4579), so there is no source
82+
for the chain to rewrite and the prescription carries no `os migrate meta`
83+
sentence.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `run-dev.js` can no longer freeze in the kernel when its stderr reader stops reading
6+
7+
Over an unbuilt workspace with the read end of its output alive but not being
8+
drained, the dev entry point is contracted to give up and exit 2 rather than
9+
wait. Intermittently it did neither: it stayed alive past every ceiling and
10+
ended only when something killed it — 27 of 30 runs on a cold `tsx` transform
11+
cache, against 1 of 90 on a warm one.
12+
13+
The bound that was supposed to stop it (`STDERR_DRAIN_STALL_MS`, polled by a
14+
50 ms `setInterval`) was not late; it was unreachable. Sampled from outside the
15+
process, the main thread was parked inside `write(2)` on fd 2 with `O_NONBLOCK`
16+
clear on that file description, so the event loop was not running and no timer,
17+
callback or promise in the file could fire. No ceiling of any size separates
18+
that from a wait, which is why raising and re-deriving one never helped.
19+
20+
The flag is not stable and nothing in this repo clears it: node sets
21+
`O_NONBLOCK` when it opens the pipe, and libuv clears it again in the pre-exec
22+
of any child spawned with inherited stdio — and because inheriting is `dup2`,
23+
the flag lives on an open file description the spawner shares, so the spawner
24+
loses it too. Under `tsx` that child is the esbuild service, started when a
25+
module has to be transformed, which is why a fresh CI checkout hits this and a
26+
warm developer box almost never does. Measured on one run: `O_NONBLOCK` true at
27+
102 ms, false at 1132 ms in the same sample the esbuild service appears in, main
28+
thread in `write(2)` from 2730 ms and never out of it.
29+
30+
`bin/run-dev.js` now re-asserts non-blocking mode on the write path before each
31+
stderr write, which cannot be outrun by a later spawn the way a one-shot at
32+
startup can. This is the inverse of the `setBlocking(true)` both this file and
33+
`src/utils/format.ts` refuse: it is what keeps their shared premise — that a
34+
write to a pipe gets buffered rather than parking the thread — true.
35+
36+
`bin/` is not named in this package's `files`, so only `bin/run.js` (the `bin`
37+
target) is packed: no published byte changes here.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/service-job': patch
3+
---
4+
5+
docs(service-job): state the scheduler leader-election guarantee with its window (#14619)
6+
7+
Documentation only — no runtime change, no type change, no accept/reject
8+
behaviour moves. It ships as a patch because the docblocks are **published
9+
bytes**: `tsup`'s declaration rollup carries `CronJobAdapter.runScheduled`'s
10+
docblock and `DbJobAdapter.schedule`'s routing docblock into `dist/index.d.ts`
11+
/ `dist/index.d.cts` (measured: with all comments stripped, the before/after
12+
declaration files are byte-identical — no exported symbol moved, no signature
13+
changed).
14+
15+
`CronJobAdapter.runScheduled()` holds its cluster lock for the duration of a
16+
scheduled fire (acquired, then released in `finally`), not for the scheduling
17+
deadline. The two docblocks stated the guarantee — "only the node that
18+
acquires the per-job lock runs the handler" — without that window, which reads
19+
as exactly-once per deadline. It is exactly-once only when replica clocks
20+
agree to within the handler's runtime (the normal case on an NTP-synced
21+
deployment); a replica whose clock lags past that window finds the lock
22+
already released and reruns the job. `once` schedules are the sharpest case,
23+
since a one-shot has no later tick during which a business-level
24+
de-duplication marker could self-correct that away. The multi-node section of
25+
[Self-Hosted Deployment](/docs/deployment/self-hosting) states the same
26+
caveat.
27+
28+
⛔ The mechanism is deliberately unchanged: holding the lease keyed to the
29+
deadline (plus takeover semantics for a leader that dies mid-fire) is a
30+
distributed-design item with zero measured pull and no measured
31+
skew-to-runtime ratio — this is bookkeeping, not closure. If a real
32+
duplicate-fire incident is measured on a `once` schedule, that remedy returns
33+
as its own card.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
fix(plugin-sharing): let system writes materialize sharing rules — drop the `isSystem` skips in `bindRuleHooks` (#13533)
6+
7+
A criteria sharing rule declares a promise: `status == "approved"` means the
8+
named recipients can see the record. `bindRuleHooks` did not keep that promise
9+
when the platform was the writer. Its `afterInsert` and `afterUpdate` hooks
10+
returned early on `ctx.session.isSystem`, so a system-context write that moved a
11+
record INTO a rule's criteria materialized no `sys_record_share` row at all.
12+
13+
The path that made this a real outage rather than a boot-time curiosity is
14+
approval write-back. An approval node with `lockRecord: true` mirrors the
15+
decision onto the subject record under a system context — that is the only write
16+
that can land while the record is locked — so a manager approving a leave
17+
request produced exactly the skip above. A teammate who relied on the rule could
18+
not see the record, and nothing repaired it until somebody ran
19+
`POST /api/v1/sharing/rules/:id/evaluate` or restarted the server. The failure
20+
was invisible from a manager or admin view, which reads through the profile's
21+
`viewAllRecords` grant and never consults a sharing rule at all.
22+
23+
**What changes.** A system write now materializes exactly as a user write does —
24+
grants on the way into a rule's criteria, revokes on the way out, per record,
25+
synchronously with the write. Three early returns are gone: the two on
26+
`afterInsert` / `afterUpdate`, and the one on the `beforeUpdate` / `beforeDelete`
27+
row-set stash they depended on (without that stash an `after` hook reads the row
28+
set as *unbounded*, which would have turned every single-row system update into
29+
an object-wide revoke plus an asynchronous re-grant). The INFO line that
30+
announced the skip — `[sharing-rule] sharing materialisation skipped for isSystem
31+
writes; re-evaluate rules or restart to backfill` — is retired with it, along
32+
with the `SYSTEM_WRITE_SKIP_NOTICE` constant, which was not exported from the
33+
package entry point. Operationally this means seed- and import-time system writes
34+
on rule-covered objects now pay per-record sharing evaluation at write time — the
35+
cost a user write of the same shape has always paid, with the
36+
`kernel:bootstrapped` backfill still reconciling behind it.
37+
38+
**What does not change.** `afterDelete` still skips system writes, on separate
39+
grounds: what it skips is revocation, and `record-share-cascade.ts` delivers that
40+
on every sharing-capable object, stashing for system writes on its own account.
41+
The `kernel:bootstrapped` boot backfill still runs and is still needed — it
42+
reaches rows no hook saw, and it is the only pass that purges a deactivated
43+
rule's grants. No new option, flag or declarative switch: the fix is the removal.
44+
45+
No published export moves; `SYSTEM_WRITE_SKIP_NOTICE` was never re-exported from
46+
the package entry point.

‎.github/workflows/validate-deps.yml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ on:
1818
# exercised on the PR that introduces them.
1919
- '.github/workflows/validate-deps.yml'
2020
schedule:
21-
# Run weekly on Monday at 03:00 UTC
22-
- cron: '0 3 * * 1'
21+
# Run daily at 03:00 UTC — narrows the discovery window for a new OSV
22+
# advisory from up to six days (weekly) to one, so the scheduled scan
23+
# is more likely to surface a red before an unrelated PR's blocking
24+
# trigger collides with it (#14645).
25+
- cron: '0 3 * * *'
2326
workflow_dispatch:
2427

2528
jobs:

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ These properties are available on **all** field types:
652652
| `description` | `string` | — | Field description / help text |
653653
| `type` | `FieldType` | **required** | One of the supported field types |
654654
| `required` | `boolean` | `false` | Whether the field is required |
655-
| `unique` | `boolean` | `false` | Enforce uniqueness |
655+
| `unique` | `boolean \| 'global' \| 'organization'` | `false` | Unique constraint **and its scope** (ADR-0120). `'organization'` = one holder per organization; `true` = that same per-organization scope (positional synonym — prefer the explicit spelling in new code); `'global'` = one holder across the whole installation; `false` = no constraint. `'tenant'`/`'org'` are rejected — the word is `'organization'`. Full rule in the `unique` doc block in [`FieldSchema`](/docs/references/data/field) |
656656
| `multiple` | `boolean` | `false` | Allow array of values (multi-record lookup, multi-select, multi-file). An emptied multi-value lookup reads back as `[]`, never `null` — the `multiple` doc block in [`FieldSchema`](/docs/references/data/field) |
657657
| `searchable` | `boolean` | `false` | Include in search index |
658658
| `sortable` | `boolean` | `true` | Allow sorting by this field |

‎content/docs/deployment/self-hosting.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,15 @@ decrypt each other's secrets. All replicas must share the same
402402
`OS_SECRET_KEY`, `OS_AUTH_SECRET`, and database. See
403403
[Cluster](/docs/kernel/cluster).
404404

405+
**Scheduled jobs' leader election has a window, not a deadline.** `cron` /
406+
`interval` / `once` schedules dedupe across replicas by holding a lock for
407+
the duration of each fire — the mutual-exclusion window is the handler's
408+
runtime, not the scheduling deadline. Exactly-once per deadline holds only
409+
when replica clocks agree to within that window, which an NTP-synced
410+
deployment gives you. A replica whose clock lags past the window finds the
411+
lock already released and reruns the job; `once` schedules are the sharpest
412+
case, since a one-shot has no later tick to self-correct on.
413+
405414
## First boot: create the admin
406415

407416
How the first administrator is created depends on the deployment's

0 commit comments

Comments
 (0)