Skip to content

Commit fb6d109

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16384-auth-base-path-single-definition
2 parents 93ac549 + 7e74af3 commit fb6d109

14 files changed

Lines changed: 1498 additions & 9 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
`os serve` now announces **`objectstack:seed-settled`** on its existing ipc channel when this boot's seeding has come to rest, and `os dev` forwards it to its own parent process when one holds the channel. A script that spawns a dev server can finally wait for the boot to finish without reading the child's output.
6+
7+
`✓ Server is ready` is true about the HTTP server and says nothing about the app. Seeding races a soft budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s) and past it finishes in the background, so the banner can be a minute ahead of the seed's own result — measured downstream at **82 seconds of silence after the banner, then 120 `ERROR` lines**. The same command on the same corpus settles before the banner on a machine where the seed fits its budget, so the defect is invisible on exactly the boxes that would have caught it. Everything that distinguishes the two cases arrives on the child's inherited stdio, and reading that costs the boot its TTY.
8+
9+
- **The producer is not new.** `@objectstack/runtime` already declares every seed source and settles it at the moment its boot-time write is done, publishing the tally under `@objectstack/spec`'s `seed-settlement` contract. This is the hop outward: the CLI subscribes to two hooks the kernel already fires and reads a snapshot it already publishes. No service is registered and no tally is mutated — the contract is read-only by design.
10+
- **Sent once, and never before `objectstack:listening`.** Seeding that settles during `runtime.start()` is latched and released after the bound port is published, so a parent that waits for the listening message and only then listens for the settle cannot miss it.
11+
- ⛔ **Keyed on `inFlight`, not `pending`.** Multi-tenant replay and `skipSeedData` register a seed source and deliberately never run it, keeping `pending` above zero for the life of the process. A `pending`-keyed message would never be sent on those boots, and its absence would be indistinguishable from a boot still writing — the same ambiguity this closes, one level up. Those boots get the message with `suppressed` reasons attached instead, so a consumer can say *why* no rows landed.
12+
- **Failure settles too.** A seed that failed has still come to rest; withholding there would recreate the hang. `ok` is a verdict on the per-source counts the boot recorded, and the message carries those counts.
13+
- **The over-budget banner no longer omits seeding.** `Seeds:` is fed by outcomes recorded when a load *finishes*, so past the budget the row was ABSENT and the transcript was byte-identical to an app that declares no seeds — which is how the defect hid. It now reads `pending — N sources still writing`, with a line saying seeding continues in the background; suppressed sources are named rather than reported as pending.
14+
15+
⛔ An ipc channel is **not** made a requirement of either command: `process.send` is undefined under an ordinary terminal boot, both sends are no-ops there, and no byte of that transcript changes. Nothing in the existing `objectstack:listening` publication moves.
16+
17+
Note that `os dev` consumes `objectstack:listening` itself (it is how the bound-port readout and the MCP connect hint learn the real port) and relays only `objectstack:seed-settled`. Spawn `os serve` directly to receive both in one place.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
Execute the read-probe's PostgreSQL catalog arm against a live server, closing the one dialect this package pinned as text and never ran.
6+
7+
`read-probe.ts` compiles one non-raising table-presence arm per dialect family. Two were executed against something real — SQLite end to end through a real `SqlDriver`, MySQL on the live server the `Temporal Conformance` job provisions. The PostgreSQL arm (`SELECT 1 WHERE to_regclass('"<table>"') IS NOT NULL`) was pinned character-for-character against all four knex client spellings and run nowhere: this package had no live-PG harness, no `pg` dependency, and its CI step supplied `OS_TEST_MYSQL_URL` alone while filtering vitest to `live-mysql`.
8+
9+
A text pin cannot close that gap, because the failure this module is fenced against is an arm mis-compiled for one dialect: it raises, the `catch` that exists for the expected miss swallows it, and a stored-row data repair silently becomes a no-op. Whether `to_regclass` answers ZERO ROWS rather than raising is a claim about PostgreSQL, not about this repo's string concatenation. `seed-tenancy-backfill.live-postgres.test.ts` now runs every statement the migration builds, both presence directions with the refusal control beside them, the search-path scoping the arm depends on, and the whole backfill end to end — on a live server, in its own derived schema. Ablated (the Postgres arm re-compiled to MySQL's `DATABASE()` form), six of its seven cases go red, reporting `verdict: 'unreadable'` with `detail: "function database() does not exist"` — the exact shape the fence exists to keep out of `'absent'`.
10+
11+
Grade: `patch`, measured rather than defaulted. Not `minor` — no new export, no widened accept-set, no runtime behaviour change of any kind. Not `skip-changeset` either, and that is the measurement worth recording: `dist/` is byte-untouched (grepped for this change's markers: zero hits, against a positive control that hits `dist/index.js` and `dist/index.cjs`), but `package.json` is one of the 27 files `npm pack` ships, and it now carries `pg` and `@types/pg` in `devDependencies`. `skip-changeset` is for a diff that publishes nothing from a released package; this one publishes two manifest lines a consumer never installs, which is still publishing.

‎.github/workflows/ci.yml‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,18 +1407,38 @@ jobs:
14071407
# paths, not shell globs — measured, the glob form matches zero files
14081408
# ("No test files found", exit 1). Loud rather than silent, but still
14091409
# wrong; the substring is the form that actually selects both.
1410+
#
1411+
# [#17621] TWO substrings now, because the dialect that can fail is no
1412+
# longer only MySQL. #17175 gave the two presence probes a catalog arm per
1413+
# dialect family, and its PostgreSQL arm — `to_regclass` — was pinned as
1414+
# TEXT and executed NOWHERE: this package had no live-PG harness and this
1415+
# step supplied `OS_TEST_MYSQL_URL` alone, so the one arm whose failure
1416+
# mode is a SILENTLY skipped data repair was the one arm no server ever
1417+
# ran. The postgres service this job already provisions for the driver-sql
1418+
# leg is what makes closing that wiring rather than infrastructure.
1419+
#
1420+
# Multiple positionals are OR-ed, and each is still a substring: measured,
1421+
# `live-mysql live-postgres` selects the three `live-mysql*` files the
1422+
# single argument selected plus `seed-tenancy-backfill.live-postgres`. ⛔ A
1423+
# single shorter substring (`live-`) would select the same set today and
1424+
# silently widen with the next unrelated file that happens to spell it.
14101425
- name: Build metadata-protocol and its dependencies
14111426
run: pnpm exec turbo run build --filter=@objectstack/metadata-protocol... --concurrency=4
14121427

1413-
- name: Run the metadata-protocol migration statements against live MySQL
1428+
- name: Run the metadata-protocol migration statements against live MySQL and PostgreSQL
14141429
env:
14151430
OS_TEST_MYSQL_URL: mysql://root:root@127.0.0.1:3306/conformance
1431+
# [#17621] The same URL the driver-sql leg above already receives, for
1432+
# the postgres service this job already runs. One line, and the
1433+
# read-probe's Postgres arm stops being the only one nothing executes.
1434+
OS_TEST_POSTGRES_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres
14161435
# Same vacuous-pass guard as the driver-sql leg: this runner
14171436
# provisioned the server, so a missing URL is a defect in the runner
1418-
# and must be a red rather than a skip.
1437+
# and must be a red rather than a skip. It now covers BOTH URLs: each
1438+
# live file above turns its own missing URL into a named failure.
14191439
OS_EXPECT_LIVE_DIALECT_MATRIX: '1'
14201440
run: |
1421-
pnpm --filter @objectstack/metadata-protocol exec vitest run live-mysql
1441+
pnpm --filter @objectstack/metadata-protocol exec vitest run live-mysql live-postgres
14221442
14231443
dogfood:
14241444
# Sharded 3-way: the suite is ~60 independent test files, each booting its

‎content/docs/deployment/cli.mdx‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,67 @@ audit) lands there instead of the business DB (ADR-0057). Opt out with
234234
`OS_TELEMETRY_DB=0`, or point it elsewhere (any mode, including `serve`)
235235
with `OS_TELEMETRY_DB=<path>`.
236236

237+
##### Waiting for the boot from a parent process
238+
239+
`✓ Server is ready` is true about the **HTTP server**, and deliberately says
240+
nothing about the app's data. Seeding races a soft budget
241+
(`OS_INLINE_SEED_BUDGET_MS`, default `8000`); when it runs long the kernel
242+
starts anyway and the rest of the seed finishes **in the background** — so the
243+
banner, and anything that waits for it, can be a minute ahead of the seed's own
244+
result. On a machine where the seed fits its budget the same command settles
245+
before the banner. Both are normal, and which one you get depends on the box.
246+
247+
So a script that spawns a dev server and wants to act **after the boot has come
248+
to rest** should not wait on the banner, and should not need to read the child's
249+
output at all. Spawn with an `ipc` channel and wait for a message:
250+
251+
| Message | Sent by | Means |
252+
|---|---|---|
253+
| `objectstack:listening` | `os serve` | The HTTP server is bound. Carries `{ port, url }` — the port actually bound, which in dev may differ from the one requested. |
254+
| `objectstack:seed-settled` | `os serve` | Nothing is still seeding. Carries `{ ok, suppressed, sources }`. Sent once per boot, always **after** `objectstack:listening`. |
255+
256+
```js
257+
import { spawn } from 'node:child_process';
258+
259+
const child = spawn('os', ['dev'], { stdio: ['inherit', 'inherit', 'inherit', 'ipc'] });
260+
261+
child.on('message', (msg) => {
262+
if (msg?.type !== 'objectstack:seed-settled') return;
263+
if (msg.suppressed.length > 0) {
264+
console.log(`boot complete — seeds not run this boot (${msg.suppressed.join(', ')})`);
265+
} else if (!msg.ok) {
266+
console.log('boot complete — but some seed records did not land; see the log above');
267+
} else {
268+
console.log('boot complete — the app is ready to use');
269+
}
270+
});
271+
```
272+
273+
**`os dev` spawns `os serve`, and the two channels are not symmetric.** `os dev`
274+
consumes `objectstack:listening` itself — it is how the `↪ server bound to port`
275+
line and the MCP connect hint learn the real port — and does **not** relay it.
276+
It forwards `objectstack:seed-settled` to its own parent verbatim. Spawn
277+
`os serve` directly if you need both messages in one place.
278+
279+
An `ipc` channel is optional: without one, both sends are no-ops and nothing
280+
about the command changes. There is no polling to do — if you did not open the
281+
channel, the messages simply are not sent.
282+
283+
<Callout type="info" title="What `objectstack:seed-settled` promises, and what it does not">
284+
It is sent when **nothing is still writing** — on success *and* on failure, since
285+
a seed that failed has still come to rest. Read `ok` together with `sources`
286+
rather than alone: `ok` is a verdict on the per-source counts the boot recorded,
287+
and a source that finished by throwing may record no counts at all.
288+
289+
`suppressed` is non-empty when this boot registered a seed source and
290+
deliberately never ran it — `multi-tenant-replay` (rows are written per
291+
organization on `sys_organization` insert) or `skip-seed-data` (a planning boot
292+
that writes nothing). Those sources never settle and no further signal is
293+
coming for them, which is exactly why the message is sent anyway with the reason
294+
attached: a consumer that waited for *every* source to finish would wait
295+
forever.
296+
</Callout>
297+
237298
#### `os serve`
238299
239300
Starts the ObjectStack server with automatic plugin discovery:
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { forwardSeedSettledToParent } from './dev.js';
5+
6+
/**
7+
* #17329 — `os dev` relays the `serve` child's settle announcement to its OWN
8+
* parent, and does nothing at all when no parent holds the channel.
9+
*
10+
* ## Why the hop is the card
11+
*
12+
* The producer already exists and is published: `@objectstack/runtime` declares
13+
* every seed source and settles it at the moment its boot-time write is done,
14+
* under the spec's `seed-settlement` contract. `serve` now announces that on the
15+
* ipc channel. But the consumer — a demo script, a test harness, anything that
16+
* spawns a dev server and wants to print one line after the boot — spawns
17+
* `os dev`, not `serve`; `os dev` runs the child over
18+
* `stdio: ['inherit','inherit','inherit','ipc']`, so without this the message
19+
* lands in the middle process and stops. One hop is the whole of what was
20+
* missing.
21+
*
22+
* ⚠️ Under vitest's `forks` pool `process.send` is the RUNNER's own control
23+
* channel. Every swap below is synchronous, spans one call, and is undone in
24+
* `finally` — a real message must never reach it.
25+
*/
26+
describe('#17329 `os dev` forwards `objectstack:seed-settled` outward', () => {
27+
/** Drive `fn` with `process.send` replaced by a recorder. */
28+
const recording = (fn: () => void): unknown[] => {
29+
const sent: unknown[] = [];
30+
const prior = process.send;
31+
(process as { send?: unknown }).send = (m: unknown) => { sent.push(m); return true; };
32+
try { fn(); } finally { (process as { send?: unknown }).send = prior; }
33+
return sent;
34+
};
35+
36+
/** Drive `fn` with NO ipc channel — the ordinary terminal `os dev`. */
37+
const withoutChannel = <T>(fn: () => T): T => {
38+
const prior = process.send;
39+
(process as { send?: unknown }).send = undefined;
40+
try { return fn(); } finally { (process as { send?: unknown }).send = prior; }
41+
};
42+
43+
const settled = {
44+
type: 'objectstack:seed-settled',
45+
ok: false,
46+
suppressed: [],
47+
sources: [{ source: 'showcase', inserted: 24, updated: 0, skipped: 0, rejected: 14 }],
48+
};
49+
50+
it('relays the message VERBATIM, not a re-derivation of it', () => {
51+
// ⛔ This process has no kernel and could only guess. Passing the object
52+
// through is what keeps `os dev`'s parent and the `serve` child from being
53+
// made to say two different things about one boot.
54+
const sent = recording(() => {
55+
expect(forwardSeedSettledToParent(settled)).toBe(true);
56+
});
57+
expect(sent).toEqual([settled]);
58+
expect(sent[0], 'the message was rebuilt rather than relayed').toBe(settled);
59+
});
60+
61+
it('⛔ a parent with no ipc channel is UNAFFECTED — no throw, no send', () => {
62+
// An ipc channel must not become a requirement of running a published
63+
// command. `process.send` is undefined under a terminal `os dev`.
64+
withoutChannel(() => {
65+
expect(() => forwardSeedSettledToParent(settled)).not.toThrow();
66+
expect(forwardSeedSettledToParent(settled), 'the message is still HANDLED here').toBe(true);
67+
});
68+
});
69+
70+
it('survives a parent channel that has already closed', () => {
71+
// Best-effort, exactly like the child's own `announceListening`: a
72+
// supervision nicety must never take a healthy dev server down.
73+
const prior = process.send;
74+
(process as { send?: unknown }).send = () => { throw new Error('channel closed'); };
75+
try {
76+
expect(() => forwardSeedSettledToParent(settled)).not.toThrow();
77+
} finally {
78+
(process as { send?: unknown }).send = prior;
79+
}
80+
});
81+
82+
describe('⛔ and it claims ONLY its own message', () => {
83+
it.each([
84+
['the listening announcement', { type: 'objectstack:listening', port: 3001, url: 'http://localhost:3001' }],
85+
['an unrelated type', { type: 'something:else' }],
86+
['no type at all', { port: 3001 }],
87+
['null', null],
88+
['undefined', undefined],
89+
['a string', 'objectstack:seed-settled'],
90+
])('%s is left to the caller', (_label, msg) => {
91+
// Returning `true` here would swallow `objectstack:listening` and take
92+
// the bound-port readout and the MCP connect hint down with it.
93+
const sent = recording(() => {
94+
expect(forwardSeedSettledToParent(msg)).toBe(false);
95+
});
96+
expect(sent, 'a message that is not ours was forwarded anyway').toEqual([]);
97+
});
98+
99+
it('…and the positive control on the same path still fires', () => {
100+
// So the zeros above are readings rather than a function that forwards
101+
// nothing at all.
102+
const sent = recording(() => { forwardSeedSettledToParent(settled); });
103+
expect(sent).toHaveLength(1);
104+
});
105+
});
106+
});

‎packages/cli/src/commands/dev.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,50 @@ export function printMcpConnectHint(
131131
console.log(chalk.dim(' Disable OS_MCP_SERVER_ENABLED=false'));
132132
}
133133

134+
/**
135+
* The hop outward: relay the `serve` child's `objectstack:seed-settled`
136+
* announcement to `os dev`'s OWN parent (#17329).
137+
*
138+
* ## Why the hop exists at all
139+
*
140+
* `os dev` is a spawner. It runs `serve --dev` over
141+
* `stdio: ['inherit','inherit','inherit','ipc']`, so the child's settle
142+
* announcement lands HERE and stops — while the consumer that needs it (a demo
143+
* script, a test harness, anything that spawns `os dev` and wants to print one
144+
* line after the boot) holds a channel to `os dev`, not to a grandchild process
145+
* it did not start and cannot name. One hop is the whole of the missing piece:
146+
* the producer already exists, and the child already announces.
147+
*
148+
* ## Relayed verbatim, deliberately
149+
*
150+
* ⛔ Nothing here re-derives, re-summarises or re-grades the message. The child
151+
* read the settlement tally off the kernel that did the seeding; this process
152+
* has no kernel and could only guess. Passing the object through means `os
153+
* dev`'s parent and the `serve` child can never be made to say two different
154+
* things about one boot — the same rule the `MCP:` row above follows for the
155+
* origin, and for the same reason.
156+
*
157+
* ## An IPC channel stays OPTIONAL for this command
158+
*
159+
* ⛔ A parent that holds no channel must be unaffected, and is: `process.send`
160+
* is `undefined` under an ordinary terminal `os dev`, so this returns having
161+
* done nothing, printed nothing, and changed no byte of that transcript. The
162+
* `serve` child's own `announceListening` is best-effort for exactly this
163+
* reason and this is its mirror — ⛔ this message does not make an IPC channel
164+
* a requirement of running a published command.
165+
*
166+
* @returns `true` when the message was a settle announcement (handled here, and
167+
* the caller should stop) — `false` for every other message, which the
168+
* caller's own branches still own.
169+
*/
170+
export function forwardSeedSettledToParent(msg: unknown): boolean {
171+
if ((msg as { type?: unknown } | null | undefined)?.type !== 'objectstack:seed-settled') return false;
172+
try {
173+
if (typeof process.send === 'function') process.send(msg);
174+
} catch { /* the parent's channel closed — best-effort, exactly like the child's */ }
175+
return true;
176+
}
177+
134178
export default class Dev extends Command {
135179
static override description =
136180
'Start development mode — watch sources, rebuild the artifact, and restart the server on change';
@@ -566,6 +610,10 @@ export default class Dev extends Command {
566610
// its HTTP server is up. We surface it so the printed URL is correct
567611
// even when the port was auto-shifted (e.g. 3000 busy → 3001).
568612
child.on('message', (msg: any) => {
613+
// #17329 — the hop outward. Handled first and exclusively: a settle
614+
// announcement carries no port and has nothing to do with the block
615+
// below. See {@link forwardSeedSettledToParent}.
616+
if (forwardSeedSettledToParent(msg)) return;
569617
if (msg?.type === 'objectstack:listening' && msg.port) {
570618
const actual = String(msg.port);
571619
if (actual !== requestedPort) {

0 commit comments

Comments
 (0)