Skip to content

Commit ca23902

Browse files
authored
Merge branch 'main' into claude/issue-14710-cli-test-typecheck
2 parents 05163f4 + f116b8f commit ca23902

28 files changed

Lines changed: 1630 additions & 368 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): the mail transport, brand and locale bindings no longer depend on `registerRoutes`
6+
7+
`AuthPlugin` bound five things to the live kernel on `kernel:ready` — the
8+
outbound mail transport (`setEmailService`), the SMS transport
9+
(`setSmsService`), the deployment email locale (`setDefaultEmailLocale`), the
10+
brand name (`setAppName`) and the SMS locale (`setDefaultSmsLocale`) — from
11+
inside the same hook that mounts `/api/v1/auth/*`, and that hook was gated on
12+
`registerRoutes`.
13+
14+
`registerRoutes` answers a transport-mounting question: should this plugin put
15+
its own routes on the kernel's `http-server`. The bindings are service
16+
composition, and they are true of an embedding regardless of who serves the
17+
routes. So an embedding that serves auth routes itself — the whole point of
18+
`registerRoutes: false` — came up with no mail transport, no locale on either
19+
channel and no brand binding. Silently: the `logger.info` lines that report the
20+
wiring were inside the same skipped block, and the `localization` settings
21+
namespace was not even read. One visible consequence was that the workspace
22+
language could not reach auth mail on such a host at all, and
23+
`/api/v1/auth/config` answered `requireEmailVerification: false` because
24+
`resolveRequireEmailVerification()` saw no transport.
25+
26+
The composition block now registers as its own unconditional
27+
`ctx.hook('kernel:ready', …)` — the shape the sibling diagnosis and dev-seed
28+
hooks in this plugin already use — placed before the route hook so a routing
29+
host keeps the ordering the single combined hook gave it.
30+
31+
Route registration itself stays gated: a `registerRoutes: false` kernel still
32+
mounts no auth routes.
33+
34+
**Behaviour change for `registerRoutes: false` embeddings.** They now resolve
35+
the `email`, `sms`, `i18n` and `settings` services at `kernel:ready`, apply
36+
`branding.workspace_name` and `localization.locale` (subscribing to both), seed
37+
the built-in auth SMS templates when phone sign-in is enabled, and emit the
38+
four wiring `info` lines. Hosts that had compensated by wiring these by hand
39+
should expect the plugin's own binding to run as well; both paths are
40+
idempotent setters, and an explicit workspace setting keeps outranking a
41+
manifest default exactly as it does on a routing host. Nothing changes for a
42+
host that leaves `registerRoutes` at its default.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/service-datasource": minor
3+
---
4+
5+
feat(datasource): size the SQL connection pool from `OS_DATABASE_POOL_MAX`
6+
7+
A multi-replica deployment had no supported way to raise the number of database
8+
connections each replica opens. The reporter measured a live 3-replica cluster
9+
whose authed data API plateaued at ~25 rps while Postgres held only ~9-21 of its
10+
200 connections: the ceiling was the client pool, and the operator had no knob
11+
for it. Adding replicas raised the ceiling; sizing the pool — the cheap half —
12+
was not expressible at all.
13+
14+
The pool for a `postgres` / `mysql` datasource is built by `buildSqlPool`, which
15+
gives every datasource that declares no `pool` block an explicit `{min: 0,
16+
max: 5}`. That includes the primary datasource: the one behind
17+
`OS_DATABASE_URL` is composed as a url and nothing else, so `max: 5` per replica
18+
was the effective ceiling on every self-hosted deployment, and it was reachable
19+
only by hand-authoring a `pool` block onto a datasource the operator does not
20+
write.
21+
22+
`buildSqlPool` now reads `OS_DATABASE_POOL_MAX`. Precedence is a declared
23+
`pool.max` first, then the env, then today's `5` — an operator knob does not
24+
override what an author wrote about their own datasource, and it is the only
25+
site that decides the unspecified case, so the ordering is expressed once.
26+
27+
**Nothing changes when the variable is unset**, which is the upgrade path for
28+
every existing deployment: the pool stays exactly `{min: 0, max: 5}`, pinned by
29+
a test whose job is to stay red if that ever drifts. A blank value is read as
30+
unset, so a declared-but-unfilled compose variable keeps today's behaviour too.
31+
32+
A value that is not a positive integer refuses the boot, naming the variable,
33+
the value it rejected and the sizing rule — rather than the lenient
34+
`Number(process.env.X ?? default)` shape, where a typo becomes `NaN` and the
35+
operator who was trying to raise the ceiling silently keeps the one they meant
36+
to leave. A pool ceiling is only ever measured in production.
37+
38+
Size it with `replicas × OS_DATABASE_POOL_MAX` below the database's
39+
`max_connections`, leaving headroom for migrations and admin connections.
40+
41+
Only `postgres` / `mysql` are affected — they are the two arms that build a
42+
pool. `memory` / `sqlite` / `sqlite-wasm` / `turso` receive no pool parameter
43+
and reject a declared one outright; the env is read inside a function those arms
44+
never call, so it cannot reach them. `OS_DATABASE_POOL_MIN` is deliberately not
45+
exposed: this path already runs `min: 0`.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(automation): a `try_catch` whose `catch` region itself fails now keeps the step record of both regions
6+
7+
`try_catch` returns a failure from three sites. #13803 taught the engine to fold
8+
a dying container's carried steps off the THROW channel, #14184 taught its
9+
returned-failure branch (`if (!result.success)`) to do the same, and #14184 also
10+
taught the first producer — a `try_catch` with no `catch` region — to supply
11+
them. The second producer was left unfolded: when a `catch` region is present
12+
and the handler itself fails, the return dropped `childSteps` entirely.
13+
14+
That is the same defect one path over, and the worst of the three for an
15+
operator, because TWO regions ran. The try region may have written rows before
16+
it failed; the handler may have written more before IT failed; the run log kept
17+
a step for neither, so the run summary folded over that log reported `acted: 0`
18+
over writes that had genuinely landed. `acted: 0` on a failed run reads as
19+
"nothing happened, safe to re-run", which for a non-idempotent region invites
20+
double-execution.
21+
22+
Closing it needed the half that was genuinely missing rather than the available
23+
one: the failed try attempts were already in scope, but the catch region ran
24+
without a `partialSteps` sink, so when the handler threw, the handler's own
25+
completed steps unwound with the stack. The catch region now receives the same
26+
sink the try region already had (`runRegion`'s fifth argument), and the failing
27+
return carries `[...failedTryAttempts, ...catchAttempts]` — failed try attempts
28+
first, because they happened first, which is the ordering the successful-catch
29+
return has always used. `runRegion`'s existing tagger supplies `regionKind:
30+
'try'` / `'catch'` and `parentNodeId` on its failure path as well as its
31+
success path, so the two halves stay distinguishable in the log.
32+
33+
Additive to the RECORD only. This return already reported failure with the same
34+
error text, already produced a `NODE_FAILURE` step, already set `$error` and was
35+
already routable by a `fault` edge; none of that moves, and neither does the
36+
successful-catch path or the retry/throw semantics. No engine change was needed
37+
— the fold that reads these steps has been in place since #14184.

.github/workflows/lint.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,9 +1963,12 @@ jobs:
19631963
# exact block green, because "has a value" and "the key is present" type
19641964
# identically. Both `skills/**` sites shipped to customers.
19651965
#
1966-
# Table-driven by the #13582 triage ruling, carrying the `$exists` family
1967-
# ONLY — each further word (the retired `$regex` spelling, #13532's
1968-
# `visibleWhen` claims) pays its own baseline on its own card.
1966+
# Table-driven by the #13582 triage ruling. #13582 shipped the `$exists`
1967+
# family alone; #13745 added the `(NoSQL)` portability gloss, the retired
1968+
# `$regex` spelling and #13532's `visibleWhen` claims, each with its own
1969+
# legitimate-usage survey. Each further word pays its own baseline on its
1970+
# own card, and the gate's `--self-test` pins the shipped row set as an
1971+
# exact enumeration.
19691972
#
19701973
# It lives in this job for the reason every other docs guard does
19711974
# (`check:doc-anchors`, `check:docs-audit-scope`, `check:role-word` above):

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1146,7 +1146,7 @@ registry? Add it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it.
11461146
pinned SHA before merging.
11471147
5. **Touched `packages/spec`? Regenerate and commit its artifacts before pushing** — § *Touched `packages/spec`*
11481148
above is the authority; note `OS_SKIP_DTS=1` greens `check:api-surface` locally and reds it in CI.
1149-
6. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural.
1149+
6. Update `ROADMAP.md` if user-facing or architectural.
11501150
7. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc
11511151
scripts. Repo must look identical to before, minus intended changes.
11521152

content/docs/deployment/environment-variables.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
4949
| `OS_DATABASE_AUTH_TOKEN` | string || Auth token for a libSQL/Turso connection (`--database-auth-token`). The vendor's own `TURSO_AUTH_TOKEN` is read as a fallback and is **not** renamed (see the third-party names note above). Ignored by every other driver — their credentials live in the URL. |
5050
| `OS_DATABASE_DRIVER` | enum | inferred | Force a specific driver when the URL is ambiguous. `memory` \| `sqlite` \| `sqlite-wasm` \| `postgres` \| `mysql` \| `mongodb` \| `turso`. `mysql` is a supported deployment target that carries three dialect caveats — two of them integrity guarantees MySQL cannot enforce at the database. Read [MySQL dialect caveats](/docs/data-modeling/drivers#mysql-dialect-caveats) before choosing it. |
5151
| `OS_DATABASE_SQLITE_JOURNAL_MODE` | enum | `wal` | Journal mode for **file-backed** SQLite. `wal` (default) lets a dev server and CLI commands share one file without blocking each other, and is what makes the `os migrate` occupancy check reliable. Set to `delete` for SQLite's rollback journal — required when the database lives on a **network filesystem** (NFS/SMB), where WAL cannot work. The setting is applied, not merely skipped: `delete` converts a database that already adopted WAL back. Ignored for `:memory:`, for the WASM SQLite driver, and for non-SQLite drivers. A per-datasource `sqliteJournalMode` in driver config outranks it. See [Journal mode](/docs/data-modeling/drivers#journal-mode-wal-and-cross-process-access). |
52+
| `OS_DATABASE_POOL_MAX` | integer | `5` | Maximum pooled connections **each replica** opens to `postgres` / `mysql`. The pool, not the database, is usually what caps authed throughput on a multi-replica deployment: at the default of 5 a 3-replica cluster reaches at most ~15 connections, which can plateau the data API while the database still has most of its `max_connections` free. Size it so **`replicas × OS_DATABASE_POOL_MAX` stays below the database's `max_connections`**, leaving headroom for migrations and admin connections — e.g. 3 replicas × 40 = 120 against `max_connections=200`. A per-datasource `pool.max` in the datasource's own definition outranks it, and with the variable unset nothing changes. A value that is not a positive integer refuses the boot rather than falling back to the default. Ignored by `memory` / `sqlite` / `sqlite-wasm` / `turso`, which open no pool — declaring a `pool` block on those is an error, not a silent drop. |
5253
| `OS_ALLOW_DRIVER_CONNECT_FAILURE` | boolean | `false` | Escape hatch for the driver-connect boot guard. By default a data driver that fails to connect at startup **refuses the boot** — a server that cannot reach its database must not report itself started and then fail every request. The same guard covers a **declared datasource** that objects bind to via `datasource: '…'`, or an `external` one with `validation.onMismatch: 'fail'`: those objects have no fallback datasource, so an unconnected one means they are all dead. Set to `1` to boot anyway, in an explicitly degraded state logged loudly at startup. There is **no reconnection**: whatever failed stays dead for the process lifetime and every query and schema sync routed to it fails. |
5354
| `OS_STORAGE_LOCAL_ROOT` | path | `./.objectstack/data/uploads` | Root directory for the local file storage adapter, relative to the process cwd (used by `os serve`'s default `storage` capability wiring). This is the same value as **Setup → Settings → File Storage → Root directory**; setting it here pins that field (it shows as locked-by-env). Renamed from `OS_STORAGE_ROOT` — see below. |
5455
| `OS_STORAGE_ROOT` | path || **Deprecated alias for `OS_STORAGE_LOCAL_ROOT`.** Still read for one release, with a startup warning; it will be removed in a future major. Rename it now. Before the rename the two halves of the platform spelled this value differently — the CLI wrote `OS_STORAGE_ROOT` while the settings service read `OS_STORAGE_LOCAL_ROOT` — so **any value other than the default was silently discarded** at startup and uploads landed in `./.objectstack/data/uploads` regardless. If you set `OS_STORAGE_ROOT` on an older release, check where your uploads actually are before assuming a backup covered them. |

content/docs/permissions/system-context.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ that silently does not happen.
9797
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` |
9898
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
9999
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
100-
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
100+
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1380` |
101101
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
102102
| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
@@ -198,7 +198,7 @@ assuming `isSystem` covers it is a documented source of bugs.
198198
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10007``10024` |
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
200200
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280``281` |
201-
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
201+
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
202202
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1522`, `:1551`; `domains/actions.ts:404` |
203203

204204
---

examples/app-multi-package/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ is one package ASSEMBLED (manifest fields plus the collections that package
1919
owns), declared by `AssembledPackageBodySchema`. `GET /api/v1/packages` on a
2020
booted instance lists both rows.
2121

22-
`orders` carries **no `scope` key** on purpose; the App's navigation lives with
23-
the App package because a package's own navigation may not point at a foreign
24-
object, while cross-package lookups (which `crm_order.account` is) are accepted.
22+
Both rows are served with **`scope: "project"`**. `defineStack` parses every
23+
`packages[]` entry through `ManifestSchema`, whose `scope` defaults to
24+
`project`, so no package of a compiled artifact is ever scope-less — what marks
25+
these two read-only is the server's own **`writable: false`** verdict (ADR-0070
26+
D2), which reads `engine.manifests` before it reads any scope.
27+
28+
The App's navigation lives with the App package because a package's own
29+
navigation may not point at a foreign object, while cross-package lookups (which
30+
`crm_order.account` is) are accepted.

examples/app-multi-package/src/packages/orders/index.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,21 @@ import { defineStack } from '@objectstack/spec';
1212
* - It declares the **same namespace** as the App package. That is what
1313
* ADR-0130 D1 buys: co-ownership of one namespace inside one artifact, so
1414
* `crm_order` keeps its name instead of becoming `orders_order`.
15-
* - It carries **no `scope` key**. `ManifestSchema.scope` defaults to
16-
* `'project'`, so a scope-less module is the row that separates the server's
17-
* writability verdict from a client-side `scope !== 'project'` heuristic
18-
* (ADR-0070 D2 / ADR-0130 Consequences row 6).
15+
* - Its served row carries **`writable: false`** — the server's OWN verdict
16+
* (ADR-0070 D2 / ADR-0130 Consequences row 6). `isWritablePackage` reads
17+
* `engine.manifests` FIRST, so a package booted from an artifact is
18+
* read-only whatever its `scope` says.
19+
*
20+
* ⛔ This module is NOT a scope-less row, and no package of a compiled artifact
21+
* can be. It authors no `scope` key, but `defineStack` parses every `packages[]`
22+
* entry through `ManifestSchema` (`spec/src/stack.zod.ts`,
23+
* `ArtifactPackageEntrySchema`), whose `scope` is `.default('project')` — so
24+
* `dist/objectstack.json` and every served row carry `scope: 'project'`. A
25+
* genuinely scope-less row exists only where a manifest reaches the registry
26+
* WITHOUT that parse: a marketplace / offline-imported package (booted, hence
27+
* read-only) or a Studio-created base via `POST /api/v1/packages` (writable).
28+
* That discriminating pair is pinned in
29+
* `packages/runtime/src/domains/packages-writable-verdict.test.ts`, not here.
1930
*
2031
* `crm_order.account` looks up an object this package does NOT own. That is
2132
* legal and is the whole point of the split: cross-package lookups are accepted

0 commit comments

Comments
 (0)