@fuzdev/fuz_app 0.83.0 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/auth/migrations.d.ts +21 -18
  2. package/dist/auth/migrations.d.ts.map +1 -1
  3. package/dist/auth/migrations.js +22 -19
  4. package/dist/db/CLAUDE.md +6 -0
  5. package/dist/db/schema_ready.d.ts +83 -0
  6. package/dist/db/schema_ready.d.ts.map +1 -0
  7. package/dist/db/schema_ready.js +103 -0
  8. package/dist/http/CLAUDE.md +3 -2
  9. package/dist/http/common_routes.d.ts +49 -0
  10. package/dist/http/common_routes.d.ts.map +1 -1
  11. package/dist/http/common_routes.js +92 -0
  12. package/dist/testing/CLAUDE.md +35 -6
  13. package/dist/testing/cross_backend/backend_config.d.ts +2 -2
  14. package/dist/testing/cross_backend/capabilities.d.ts +10 -0
  15. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  16. package/dist/testing/cross_backend/capabilities.js +1 -0
  17. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  18. package/dist/testing/cross_backend/default_backend_configs.js +6 -0
  19. package/dist/testing/cross_backend/default_spine_surface.d.ts +48 -0
  20. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  21. package/dist/testing/cross_backend/default_spine_surface.js +24 -24
  22. package/dist/testing/cross_backend/expected_schema.json +113 -0
  23. package/dist/testing/cross_backend/ready.d.ts +14 -0
  24. package/dist/testing/cross_backend/ready.d.ts.map +1 -0
  25. package/dist/testing/cross_backend/ready.js +50 -0
  26. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +39 -0
  27. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -0
  28. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +103 -0
  29. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +1 -1
  30. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  31. package/dist/testing/cross_backend/ts_spine_backend_config.js +6 -2
  32. package/dist/testing/schema_ready_fixture.d.ts +46 -0
  33. package/dist/testing/schema_ready_fixture.d.ts.map +1 -0
  34. package/dist/testing/schema_ready_fixture.js +48 -0
  35. package/package.json +7 -3
  36. package/dist/testing/cross_backend/spine_stub_backend_config.d.ts +0 -66
  37. package/dist/testing/cross_backend/spine_stub_backend_config.d.ts.map +0 -1
  38. package/dist/testing/cross_backend/spine_stub_backend_config.js +0 -53
@@ -4,35 +4,38 @@
4
4
  * Ordered list of `{name, up}` migrations for the fuz identity system tables.
5
5
  * Consumed by `run_migrations` with namespace `'fuz_auth'`.
6
6
  *
7
- * **Schema is not stabilized yetappend-only is NOT the rule.** While
8
- * fuz_app is pre-stable, migration bodies, names, and positions can change
9
- * freely between versions; consumers upgrading across a schema change are
10
- * expected to drop and re-bootstrap their dev/test databases (production
11
- * deployments are not yet a supported use case). Once the schema is
12
- * declared stable a hard append-only-after-publish rule will apply and the
13
- * cliff will be called out in the release notes for that version. Until
14
- * then: edit, rename, reorder, or replace migrations as needed; bias toward
15
- * collapsing work into the existing v0/v1 entries rather than appending v2
16
- * patch migrations.
17
- *
18
- * To add a migration in the pre-stable phase, prefer extending an existing
19
- * entry's body (consumers will re-bootstrap on upgrade). If you do append
20
- * a new entry to `auth_migrations`, the runner will apply it on existing
21
- * tracker rows — the same shape that will become mandatory once the
22
- * schema stabilizes:
7
+ * **The released chain is frozenevery schema change ships as an appended
8
+ * migration.** Once a consumer holds a long-lived production database, an
9
+ * already-bootstrapped DB has recorded the existing migrations as applied, so
10
+ * editing a released migration's body in place is a silent no-op there: the
11
+ * `CREATE TABLE IF NOT EXISTS` doesn't re-run, the runner sees nothing new,
12
+ * and the new column never lands a silent, total auth outage. (The
13
+ * `deleted_at` / `deleted_by` soft-delete columns were added to v0's base DDL
14
+ * this way; an older deployed DB never got them and every login broke.) So:
15
+ * never edit, rename, reorder, or re-purpose an entry in `auth_migrations`
16
+ * below. Add every additive change as a NEW appended entry using idempotent
17
+ * `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` — a fresh bootstrap and an old
18
+ * deployed DB then converge on the same shape:
23
19
  *
24
20
  * ```ts
25
21
  * // v2: add display_name to account
26
22
  * {
27
23
  * name: 'account_display_name',
28
24
  * up: async (db) => {
29
- * await db.query('ALTER TABLE account ADD COLUMN display_name TEXT');
25
+ * await db.query('ALTER TABLE account ADD COLUMN IF NOT EXISTS display_name TEXT');
30
26
  * },
31
27
  * },
32
28
  * ```
33
29
  *
30
+ * The `/ready` schema-drift probe (`db/schema_ready.ts`) is the runtime net:
31
+ * it fails the deploy loud when a live DB is missing a column the running code
32
+ * expects, rather than letting auth break silently. Discipline prevents the
33
+ * drift; the probe catches a lapse before cutover.
34
+ *
34
35
  * Migrations are forward-only (no down). Use `IF NOT EXISTS` / `IF EXISTS`
35
- * for DDL safety. The `name` appears in error messages on failure.
36
+ * for DDL safety. The `name` appears in error messages on failure. Dev/test
37
+ * DBs (no long-lived data) may still drop + re-bootstrap freely on a break —
38
+ * the freeze is the contract for the deployed chain, not local iteration.
36
39
  *
37
40
  * @module
38
41
  */
@@ -1 +1 @@
1
- {"version":3,"file":"migrations.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/migrations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AA+BH,OAAO,KAAK,EAAC,SAAS,EAAE,kBAAkB,EAAC,MAAM,kBAAkB,CAAC;AAEpE,wDAAwD;AACxD,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,EAAE,aAAa,CAAC,MAAM,CAA8B,CAAC;AAE/F;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,SAAS,CAsF5C,CAAC;AAEF,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,EAAE,kBAG/B,CAAC"}
1
+ {"version":3,"file":"migrations.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/migrations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AA+BH,OAAO,KAAK,EAAC,SAAS,EAAE,kBAAkB,EAAC,MAAM,kBAAkB,CAAC;AAEpE,wDAAwD;AACxD,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,EAAE,aAAa,CAAC,MAAM,CAA8B,CAAC;AAE/F;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,SAAS,CAsF5C,CAAC;AAEF,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,EAAE,kBAG/B,CAAC"}
@@ -4,35 +4,38 @@
4
4
  * Ordered list of `{name, up}` migrations for the fuz identity system tables.
5
5
  * Consumed by `run_migrations` with namespace `'fuz_auth'`.
6
6
  *
7
- * **Schema is not stabilized yetappend-only is NOT the rule.** While
8
- * fuz_app is pre-stable, migration bodies, names, and positions can change
9
- * freely between versions; consumers upgrading across a schema change are
10
- * expected to drop and re-bootstrap their dev/test databases (production
11
- * deployments are not yet a supported use case). Once the schema is
12
- * declared stable a hard append-only-after-publish rule will apply and the
13
- * cliff will be called out in the release notes for that version. Until
14
- * then: edit, rename, reorder, or replace migrations as needed; bias toward
15
- * collapsing work into the existing v0/v1 entries rather than appending v2
16
- * patch migrations.
17
- *
18
- * To add a migration in the pre-stable phase, prefer extending an existing
19
- * entry's body (consumers will re-bootstrap on upgrade). If you do append
20
- * a new entry to `auth_migrations`, the runner will apply it on existing
21
- * tracker rows — the same shape that will become mandatory once the
22
- * schema stabilizes:
7
+ * **The released chain is frozenevery schema change ships as an appended
8
+ * migration.** Once a consumer holds a long-lived production database, an
9
+ * already-bootstrapped DB has recorded the existing migrations as applied, so
10
+ * editing a released migration's body in place is a silent no-op there: the
11
+ * `CREATE TABLE IF NOT EXISTS` doesn't re-run, the runner sees nothing new,
12
+ * and the new column never lands a silent, total auth outage. (The
13
+ * `deleted_at` / `deleted_by` soft-delete columns were added to v0's base DDL
14
+ * this way; an older deployed DB never got them and every login broke.) So:
15
+ * never edit, rename, reorder, or re-purpose an entry in `auth_migrations`
16
+ * below. Add every additive change as a NEW appended entry using idempotent
17
+ * `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` — a fresh bootstrap and an old
18
+ * deployed DB then converge on the same shape:
23
19
  *
24
20
  * ```ts
25
21
  * // v2: add display_name to account
26
22
  * {
27
23
  * name: 'account_display_name',
28
24
  * up: async (db) => {
29
- * await db.query('ALTER TABLE account ADD COLUMN display_name TEXT');
25
+ * await db.query('ALTER TABLE account ADD COLUMN IF NOT EXISTS display_name TEXT');
30
26
  * },
31
27
  * },
32
28
  * ```
33
29
  *
30
+ * The `/ready` schema-drift probe (`db/schema_ready.ts`) is the runtime net:
31
+ * it fails the deploy loud when a live DB is missing a column the running code
32
+ * expects, rather than letting auth break silently. Discipline prevents the
33
+ * drift; the probe catches a lapse before cutover.
34
+ *
34
35
  * Migrations are forward-only (no down). Use `IF NOT EXISTS` / `IF EXISTS`
35
- * for DDL safety. The `name` appears in error messages on failure.
36
+ * for DDL safety. The `name` appears in error messages on failure. Dev/test
37
+ * DBs (no long-lived data) may still drop + re-bootstrap freely on a break —
38
+ * the freeze is the contract for the deployed chain, not local iteration.
36
39
  *
37
40
  * @module
38
41
  */
@@ -70,7 +73,7 @@ export const reserved_migration_namespaces = [AUTH_MIGRATION_NAMESPACE];
70
73
  * v2 may add INSERT-time `(role, scope_kind)` enforcement.
71
74
  */
72
75
  export const auth_migrations = [
73
- // v0: full auth schema — all IF NOT EXISTS, safe for existing databases
76
+ // v0: full auth schema (frozen never edit this body; see module doc)
74
77
  {
75
78
  name: 'full_auth_schema',
76
79
  up: async (db) => {
package/dist/db/CLAUDE.md CHANGED
@@ -25,6 +25,12 @@ the code.
25
25
  - `pg_error.ts` — `is_pg_unique_violation` (Postgres `23505`).
26
26
  - `sql_identifier.ts` — `assert_valid_sql_identifier`.
27
27
  - `status.ts` — CLI DB status utility.
28
+ - `schema_ready.ts` — `/ready` deploy-gate core: `query_public_columns`
29
+ (keeps `schema_version`, unlike `query_schema_snapshot`), pure
30
+ `check_schema_drift` / `format_schema_drift`, `READY_ERROR`. Column-presence
31
+ drift detection only (engine-portable); the HTTP route + fixture loader live
32
+ in `http/common_routes.ts`, the gen-time regen helper in
33
+ `testing/schema_ready_fixture.ts`.
28
34
 
29
35
  ## Cell layer
30
36
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Readiness probe core: live-DB schema-drift detection.
3
+ *
4
+ * `/health` is a dumb liveness probe (no DB). `/ready` is the deploy gate — it
5
+ * introspects the live database's column set and compares it against a
6
+ * committed expected column map (what a fresh full migration-chain bootstrap
7
+ * produces). A live DB missing an expected column is exactly the failure mode
8
+ * that silently broke login when the auth schema gained `account.deleted_at`
9
+ * via an in-place base-DDL edit instead of an appended migration: the deployed
10
+ * code required a column an older bootstrapped DB never got, and a `SELECT *` +
11
+ * JS `deleted_at === null` filter rejected every account. The `/ready` route
12
+ * (`http/common_routes.ts`) turns that drift into a loud `503` so a deploy poll
13
+ * rolls the release back instead of promoting code that can't authenticate
14
+ * anyone. The discipline that prevents the drift is the frozen append-only
15
+ * migration chain (`auth/migrations.ts`); this probe is the runtime net for a
16
+ * lapse.
17
+ *
18
+ * The check is intentionally **column-presence only** — not type / constraint /
19
+ * index parity. Column names are DDL-deterministic and engine-portable, so a
20
+ * map generated against PGlite at gen-time compares exactly against a live
21
+ * Postgres at runtime; finer-grained parity would false-positive across the two
22
+ * engines, and a false positive here means a rolled-back deploy — an outage you
23
+ * caused. Full structural parity stays the dev-time cross-backend
24
+ * schema-snapshot suite's job (`testing/schema_introspect.ts`). In-place *type*
25
+ * changes (a column kept by name, retyped) are out of scope — they rely on the
26
+ * query-time column-named failures instead.
27
+ *
28
+ * This module is pure DB introspection + comparison: no HTTP, no filesystem, no
29
+ * fixture-path knowledge. The route factory and the committed-fixture loader
30
+ * live in `http/common_routes.ts`; the gen-time fixture-regeneration helper
31
+ * lives in `testing/schema_ready_fixture.ts`.
32
+ *
33
+ * @module
34
+ */
35
+ import type { Db } from './db.js';
36
+ /** Expected schema: table name → sorted column names, from a fresh bootstrap. */
37
+ export type ExpectedSchema = Record<string, ReadonlyArray<string>>;
38
+ /**
39
+ * Introspect every column in the `public` schema, grouped by relation. Shared
40
+ * by the runtime `/ready` check and the fixture-generating helper so both
41
+ * observe the exact same shape. `information_schema.columns` spans tables **and
42
+ * views**; for the drift check that's harmless (a never-bootstrapped schema has
43
+ * neither, and extra relations are ignored — see `check_schema_drift`).
44
+ *
45
+ * Unlike `query_schema_snapshot` (which excludes the `schema_version` migration
46
+ * tracker as framework bookkeeping), this **keeps** `schema_version` — a
47
+ * never-migrated DB then correctly fails readiness instead of passing on an
48
+ * empty expectation.
49
+ *
50
+ * @returns relation name → sorted column names
51
+ */
52
+ export declare const query_public_columns: (db: Db) => Promise<Record<string, Array<string>>>;
53
+ /** Columns the live DB is missing for a table the expected schema declares. */
54
+ export interface MissingColumns {
55
+ table: string;
56
+ columns: Array<string>;
57
+ }
58
+ /** Outcome of a schema-drift check. */
59
+ export interface SchemaDriftResult {
60
+ ok: boolean;
61
+ /** Expected tables absent from the live DB. */
62
+ missing_tables: Array<string>;
63
+ /** Per-table columns the expected schema declares that the live DB lacks. */
64
+ missing_columns: Array<MissingColumns>;
65
+ }
66
+ /**
67
+ * Compare the live DB's columns against `expected`. Reports tables and columns
68
+ * the running code expects that the live DB lacks — the drift that breaks
69
+ * queries. Extra live tables / columns are ignored: forward-compatible, and a
70
+ * newer-than-fixture DB shouldn't fail readiness.
71
+ *
72
+ * @param db - live database to introspect
73
+ * @param expected - the committed column map a fresh bootstrap produces
74
+ */
75
+ export declare const check_schema_drift: (db: Db, expected: ExpectedSchema) => Promise<SchemaDriftResult>;
76
+ /** Render a drift result as a one-issue-per-line operator string. */
77
+ export declare const format_schema_drift: (drift: SchemaDriftResult) => string;
78
+ /** Error codes a readiness check returns at `503` (conforms to `{error: string}`). */
79
+ export declare const READY_ERROR: {
80
+ readonly schema_drift: "schema_drift";
81
+ readonly db_unreachable: "db_unreachable";
82
+ };
83
+ //# sourceMappingURL=schema_ready.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema_ready.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/schema_ready.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,KAAK,EAAC,EAAE,EAAC,MAAM,SAAS,CAAC;AAEhC,iFAAiF;AACjF,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC;AAOnE;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,oBAAoB,GAAU,IAAI,EAAE,KAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,CAWxF,CAAC;AAEF,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,uCAAuC;AACvC,MAAM,WAAW,iBAAiB;IACjC,EAAE,EAAE,OAAO,CAAC;IACZ,+CAA+C;IAC/C,cAAc,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC9B,6EAA6E;IAC7E,eAAe,EAAE,KAAK,CAAC,cAAc,CAAC,CAAC;CACvC;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC9B,IAAI,EAAE,EACN,UAAU,cAAc,KACtB,OAAO,CAAC,iBAAiB,CAmB3B,CAAC;AAEF,qEAAqE;AACrE,eAAO,MAAM,mBAAmB,GAAI,OAAO,iBAAiB,KAAG,MAO9D,CAAC;AAEF,sFAAsF;AACtF,eAAO,MAAM,WAAW;;;CAGd,CAAC"}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Readiness probe core: live-DB schema-drift detection.
3
+ *
4
+ * `/health` is a dumb liveness probe (no DB). `/ready` is the deploy gate — it
5
+ * introspects the live database's column set and compares it against a
6
+ * committed expected column map (what a fresh full migration-chain bootstrap
7
+ * produces). A live DB missing an expected column is exactly the failure mode
8
+ * that silently broke login when the auth schema gained `account.deleted_at`
9
+ * via an in-place base-DDL edit instead of an appended migration: the deployed
10
+ * code required a column an older bootstrapped DB never got, and a `SELECT *` +
11
+ * JS `deleted_at === null` filter rejected every account. The `/ready` route
12
+ * (`http/common_routes.ts`) turns that drift into a loud `503` so a deploy poll
13
+ * rolls the release back instead of promoting code that can't authenticate
14
+ * anyone. The discipline that prevents the drift is the frozen append-only
15
+ * migration chain (`auth/migrations.ts`); this probe is the runtime net for a
16
+ * lapse.
17
+ *
18
+ * The check is intentionally **column-presence only** — not type / constraint /
19
+ * index parity. Column names are DDL-deterministic and engine-portable, so a
20
+ * map generated against PGlite at gen-time compares exactly against a live
21
+ * Postgres at runtime; finer-grained parity would false-positive across the two
22
+ * engines, and a false positive here means a rolled-back deploy — an outage you
23
+ * caused. Full structural parity stays the dev-time cross-backend
24
+ * schema-snapshot suite's job (`testing/schema_introspect.ts`). In-place *type*
25
+ * changes (a column kept by name, retyped) are out of scope — they rely on the
26
+ * query-time column-named failures instead.
27
+ *
28
+ * This module is pure DB introspection + comparison: no HTTP, no filesystem, no
29
+ * fixture-path knowledge. The route factory and the committed-fixture loader
30
+ * live in `http/common_routes.ts`; the gen-time fixture-regeneration helper
31
+ * lives in `testing/schema_ready_fixture.ts`.
32
+ *
33
+ * @module
34
+ */
35
+ /**
36
+ * Introspect every column in the `public` schema, grouped by relation. Shared
37
+ * by the runtime `/ready` check and the fixture-generating helper so both
38
+ * observe the exact same shape. `information_schema.columns` spans tables **and
39
+ * views**; for the drift check that's harmless (a never-bootstrapped schema has
40
+ * neither, and extra relations are ignored — see `check_schema_drift`).
41
+ *
42
+ * Unlike `query_schema_snapshot` (which excludes the `schema_version` migration
43
+ * tracker as framework bookkeeping), this **keeps** `schema_version` — a
44
+ * never-migrated DB then correctly fails readiness instead of passing on an
45
+ * empty expectation.
46
+ *
47
+ * @returns relation name → sorted column names
48
+ */
49
+ export const query_public_columns = async (db) => {
50
+ const rows = await db.query(`SELECT table_name, column_name FROM information_schema.columns
51
+ WHERE table_schema = 'public'
52
+ ORDER BY table_name, column_name`);
53
+ const by_table = {};
54
+ for (const { table_name, column_name } of rows) {
55
+ (by_table[table_name] ??= []).push(column_name);
56
+ }
57
+ return by_table;
58
+ };
59
+ /**
60
+ * Compare the live DB's columns against `expected`. Reports tables and columns
61
+ * the running code expects that the live DB lacks — the drift that breaks
62
+ * queries. Extra live tables / columns are ignored: forward-compatible, and a
63
+ * newer-than-fixture DB shouldn't fail readiness.
64
+ *
65
+ * @param db - live database to introspect
66
+ * @param expected - the committed column map a fresh bootstrap produces
67
+ */
68
+ export const check_schema_drift = async (db, expected) => {
69
+ const live = await query_public_columns(db);
70
+ const missing_tables = [];
71
+ const missing_columns = [];
72
+ for (const [table, columns] of Object.entries(expected)) {
73
+ const live_columns = live[table];
74
+ if (!live_columns) {
75
+ missing_tables.push(table);
76
+ continue;
77
+ }
78
+ const live_set = new Set(live_columns);
79
+ const missing = columns.filter((column) => !live_set.has(column));
80
+ if (missing.length > 0)
81
+ missing_columns.push({ table, columns: missing });
82
+ }
83
+ return {
84
+ ok: missing_tables.length === 0 && missing_columns.length === 0,
85
+ missing_tables,
86
+ missing_columns,
87
+ };
88
+ };
89
+ /** Render a drift result as a one-issue-per-line operator string. */
90
+ export const format_schema_drift = (drift) => {
91
+ const lines = [];
92
+ for (const table of drift.missing_tables)
93
+ lines.push(` missing table: ${table}`);
94
+ for (const { table, columns } of drift.missing_columns) {
95
+ lines.push(` ${table} missing columns: ${columns.join(', ')}`);
96
+ }
97
+ return lines.join('\n');
98
+ };
99
+ /** Error codes a readiness check returns at `503` (conforms to `{error: string}`). */
100
+ export const READY_ERROR = {
101
+ schema_drift: 'schema_drift',
102
+ db_unreachable: 'db_unreachable',
103
+ };
@@ -27,7 +27,7 @@ effects, see ../../../docs/architecture.md.
27
27
  - `http/jsonrpc.ts` — JSON-RPC 2.0 envelope schemas (MCP superset), `JsonrpcErrorCode`, `_meta`.
28
28
  - `http/jsonrpc_errors.ts` — `ThrownJsonrpcError`, `jsonrpc_errors` throwers, HTTP-status mappings.
29
29
  - `http/jsonrpc_helpers.ts` — message builders, type guards, input/result normalizers.
30
- - `http/common_routes.ts` — health check + authenticated server-status + surface route specs.
30
+ - `http/common_routes.ts` — health check + readiness probe (`/ready` schema-drift deploy gate) + authenticated server-status + surface route specs.
31
31
  - `http/db_routes.ts` — generic keeper-only table browser route specs (public schema).
32
32
  - `http/pending_effects.ts` — `emit_after_commit` + `flush_pending_effects` + `flush_post_commit_effects` + `EmitAfterCommitContext`.
33
33
 
@@ -459,10 +459,11 @@ at send time.
459
459
 
460
460
  ## Common Routes
461
461
 
462
- `http/common_routes.ts` exposes three generic route-spec factories with no
462
+ `http/common_routes.ts` exposes four generic route-spec factories with no
463
463
  auth-domain dependencies:
464
464
 
465
465
  - `create_health_route_spec()` — `GET /health`, public, returns `{status: 'ok'}`. Infrastructure endpoint for uptime monitors
466
+ - `create_ready_route_spec({expected, log})` — `GET /ready`, public, the deploy gate. Introspects the live DB's columns (`db/schema_ready.ts`) and compares against the committed `expected` column map: `200 {ready: true}` on match, else `503 {error: 'schema_drift' | 'db_unreachable'}`. Detailed drift logs server-side only (minimal body — no schema leak, mirrors why `/api/surface` is authenticated). Throws at assembly on an empty `expected` (fail-loud). Opt-in like `/health`; pair with `load_expected_schema(url)` — loads + URL-caches a consumer's committed `expected_schema.json` and throws on an empty map
466
467
  - `create_server_status_route_spec({version, get_uptime_ms})` — `GET /api/server/status`, authenticated, returns `{version, uptime_ms}`
467
468
  - `create_surface_route_spec({surface})` — `GET /api/surface`, authenticated, serves the `AppSurface` JSON. Authenticated because surface data reveals API structure (schemas, auth, routes)
468
469
 
@@ -6,8 +6,10 @@
6
6
  *
7
7
  * @module
8
8
  */
9
+ import type { Logger } from '@fuzdev/fuz_util/log.js';
9
10
  import type { RouteSpec } from './route_spec.js';
10
11
  import type { AppSurface } from './surface.js';
12
+ import { type ExpectedSchema } from '../db/schema_ready.js';
11
13
  /**
12
14
  * Create a public health check route spec.
13
15
  *
@@ -15,6 +17,53 @@ import type { AppSurface } from './surface.js';
15
17
  * Bootstrap availability is exposed via `/api/account/status` instead.
16
18
  */
17
19
  export declare const create_health_route_spec: () => RouteSpec;
20
+ /**
21
+ * Load a consumer's committed `expected_schema.json` fixture (cached by URL).
22
+ *
23
+ * The spine ships the readiness *mechanism* but not the *expectation* — the
24
+ * expected column map is per-consumer (each adds its own tables), so the
25
+ * consumer commits the fixture and passes the loaded map to
26
+ * `create_ready_route_spec`. Call with an `import.meta.url`-relative URL:
27
+ *
28
+ * ```ts
29
+ * create_ready_route_spec({
30
+ * expected: load_expected_schema(new URL('./expected_schema.json', import.meta.url)),
31
+ * log: deps.log,
32
+ * });
33
+ * ```
34
+ *
35
+ * The fixture is regenerated against a fresh bootstrap by the consumer's
36
+ * gen-time test (see `testing/schema_ready_fixture.ts`), so it can't silently
37
+ * fall behind the migration chain.
38
+ *
39
+ * @param url - the fixture location (a file URL or path)
40
+ */
41
+ export declare const load_expected_schema: (url: URL | string) => ExpectedSchema;
42
+ /** Options for the readiness probe route. */
43
+ export interface ReadyRouteOptions {
44
+ /**
45
+ * The committed expected column map — typically `load_expected_schema(url)`.
46
+ * DI'd because the spine can't resolve a path relative to the consumer's
47
+ * fixture.
48
+ */
49
+ expected: ExpectedSchema;
50
+ /** Logger for server-side drift diagnostics (the public body stays minimal). */
51
+ log?: Logger;
52
+ }
53
+ /**
54
+ * Create the `/ready` readiness route spec — the deploy gate.
55
+ *
56
+ * Returns `200 {ready: true}` when the live DB's columns cover `expected`,
57
+ * else `503 {error}` (`schema_drift` when columns are missing, `db_unreachable`
58
+ * when the introspection query throws). The detailed drift goes to the server
59
+ * log only — the public body stays a minimal code so the endpoint doesn't leak
60
+ * schema structure (mirrors why `/api/surface` is authenticated). A deploy poll
61
+ * treats `503` as a failed release and rolls back, turning a silent
62
+ * schema-drift auth outage into a loud blocked deploy. See `db/schema_ready.ts`
63
+ * for the column-presence rationale and `auth/migrations.ts` for the
64
+ * frozen-append discipline that prevents the drift in the first place.
65
+ */
66
+ export declare const create_ready_route_spec: (options: ReadyRouteOptions) => RouteSpec;
18
67
  /** Options for the authenticated server status route. */
19
68
  export interface ServerStatusOptions {
20
69
  /** Application version string. */
@@ -1 +1 @@
1
- {"version":3,"file":"common_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/common_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,cAAc,CAAC;AAE7C;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,QAAO,SAQ1C,CAAC;AAEH,yDAAyD;AACzD,MAAM,WAAW,mBAAmB;IACnC,kCAAkC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,aAAa,EAAE,MAAM,MAAM,CAAC;CAC5B;AAED;;;;;GAKG;AACH,eAAO,MAAM,+BAA+B,GAAI,SAAS,mBAAmB,KAAG,SAQ7E,CAAC;AAEH,8CAA8C;AAC9C,MAAM,WAAW,mBAAmB;IACnC,0CAA0C;IAC1C,OAAO,EAAE,UAAU,CAAC;CACpB;AAED;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,GAAI,SAAS,mBAAmB,KAAG,SAWvE,CAAC"}
1
+ {"version":3,"file":"common_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/common_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,cAAc,CAAC;AAC7C,OAAO,EAIN,KAAK,cAAc,EACnB,MAAM,uBAAuB,CAAC;AAE/B;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,QAAO,SAQ1C,CAAC;AAKH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,GAAG,GAAG,MAAM,KAAG,cAkBxD,CAAC;AAEF,6CAA6C;AAC7C,MAAM,WAAW,iBAAiB;IACjC;;;;OAIG;IACH,QAAQ,EAAE,cAAc,CAAC;IACzB,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,GAAI,SAAS,iBAAiB,KAAG,SAoCpE,CAAC;AAEF,yDAAyD;AACzD,MAAM,WAAW,mBAAmB;IACnC,kCAAkC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,aAAa,EAAE,MAAM,MAAM,CAAC;CAC5B;AAED;;;;;GAKG;AACH,eAAO,MAAM,+BAA+B,GAAI,SAAS,mBAAmB,KAAG,SAQ7E,CAAC;AAEH,8CAA8C;AAC9C,MAAM,WAAW,mBAAmB;IACnC,0CAA0C;IAC1C,OAAO,EAAE,UAAU,CAAC;CACpB;AAED;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,GAAI,SAAS,mBAAmB,KAAG,SAWvE,CAAC"}
@@ -6,7 +6,9 @@
6
6
  *
7
7
  * @module
8
8
  */
9
+ import { readFileSync } from 'node:fs';
9
10
  import { z } from 'zod';
11
+ import { check_schema_drift, format_schema_drift, READY_ERROR, } from '../db/schema_ready.js';
10
12
  /**
11
13
  * Create a public health check route spec.
12
14
  *
@@ -22,6 +24,96 @@ export const create_health_route_spec = () => ({
22
24
  input: z.null(),
23
25
  output: z.strictObject({ status: z.literal('ok') }),
24
26
  });
27
+ /** Module-level cache of loaded expected-schema fixtures, keyed by URL. */
28
+ const expected_schema_cache = new Map();
29
+ /**
30
+ * Load a consumer's committed `expected_schema.json` fixture (cached by URL).
31
+ *
32
+ * The spine ships the readiness *mechanism* but not the *expectation* — the
33
+ * expected column map is per-consumer (each adds its own tables), so the
34
+ * consumer commits the fixture and passes the loaded map to
35
+ * `create_ready_route_spec`. Call with an `import.meta.url`-relative URL:
36
+ *
37
+ * ```ts
38
+ * create_ready_route_spec({
39
+ * expected: load_expected_schema(new URL('./expected_schema.json', import.meta.url)),
40
+ * log: deps.log,
41
+ * });
42
+ * ```
43
+ *
44
+ * The fixture is regenerated against a fresh bootstrap by the consumer's
45
+ * gen-time test (see `testing/schema_ready_fixture.ts`), so it can't silently
46
+ * fall behind the migration chain.
47
+ *
48
+ * @param url - the fixture location (a file URL or path)
49
+ */
50
+ export const load_expected_schema = (url) => {
51
+ const key = url.toString();
52
+ let cached = expected_schema_cache.get(key);
53
+ if (!cached) {
54
+ cached = JSON.parse(readFileSync(url, 'utf8'));
55
+ // Fail loud at load: an empty map silently passes readiness for any live
56
+ // DB, neutering the gate it exists to provide. A real fixture always has
57
+ // at least `schema_version` + the consumer's tables.
58
+ if (Object.keys(cached).length === 0) {
59
+ throw new Error(`load_expected_schema: ${key} parsed to an empty schema map — a readiness gate with ` +
60
+ `no expected tables passes for any live DB. Regenerate the fixture (see ` +
61
+ `testing/schema_ready_fixture.ts).`);
62
+ }
63
+ expected_schema_cache.set(key, cached);
64
+ }
65
+ return cached;
66
+ };
67
+ /**
68
+ * Create the `/ready` readiness route spec — the deploy gate.
69
+ *
70
+ * Returns `200 {ready: true}` when the live DB's columns cover `expected`,
71
+ * else `503 {error}` (`schema_drift` when columns are missing, `db_unreachable`
72
+ * when the introspection query throws). The detailed drift goes to the server
73
+ * log only — the public body stays a minimal code so the endpoint doesn't leak
74
+ * schema structure (mirrors why `/api/surface` is authenticated). A deploy poll
75
+ * treats `503` as a failed release and rolls back, turning a silent
76
+ * schema-drift auth outage into a loud blocked deploy. See `db/schema_ready.ts`
77
+ * for the column-presence rationale and `auth/migrations.ts` for the
78
+ * frozen-append discipline that prevents the drift in the first place.
79
+ */
80
+ export const create_ready_route_spec = (options) => {
81
+ // Fail loud at assembly: an empty `expected` makes `/ready` answer 200 for
82
+ // any live DB (the drift loop has nothing to miss), silently disabling the
83
+ // deploy gate. Catch the misconfiguration at boot, not in production.
84
+ if (Object.keys(options.expected).length === 0) {
85
+ throw new Error('create_ready_route_spec: `expected` is empty — a readiness gate with no expected ' +
86
+ 'tables passes for any live DB. Pass a non-empty expected column map.');
87
+ }
88
+ return {
89
+ method: 'GET',
90
+ path: '/ready',
91
+ auth: { account: 'none', actor: 'none' },
92
+ description: 'Readiness probe — verifies the live DB schema matches the expected column map',
93
+ input: z.null(),
94
+ output: z.strictObject({ ready: z.literal(true) }),
95
+ errors: {
96
+ 503: z.strictObject({
97
+ error: z.enum([READY_ERROR.schema_drift, READY_ERROR.db_unreachable]),
98
+ }),
99
+ },
100
+ handler: async (c, route) => {
101
+ try {
102
+ const drift = await check_schema_drift(route.db, options.expected);
103
+ if (drift.ok)
104
+ return c.json({ ready: true });
105
+ // Detailed drift goes to the server log only — the public body stays a
106
+ // minimal error code so the endpoint doesn't leak schema structure.
107
+ options.log?.error(`[ready] schema drift detected:\n${format_schema_drift(drift)}`);
108
+ return c.json({ error: READY_ERROR.schema_drift }, 503);
109
+ }
110
+ catch (err) {
111
+ options.log?.error('[ready] readiness check failed (db unreachable?):', err);
112
+ return c.json({ error: READY_ERROR.db_unreachable }, 503);
113
+ }
114
+ },
115
+ };
116
+ };
25
117
  /**
26
118
  * Create an authenticated server status route spec.
27
119
  *
@@ -853,7 +853,8 @@ source of truth for wire-shape conformance.
853
853
 
854
854
  - `testing/cross_backend/capabilities.ts` — `BackendCapabilities` vocabulary
855
855
  (`bearer_auth` / `trusted_proxy` / `login_rate_limit` / `ws` / `sse` /
856
- `cell_crud` / `cell_relations` / `account_lifecycle` / `fact_serving`),
856
+ `cell_crud` / `cell_relations` / `account_lifecycle` / `fact_serving` /
857
+ `ready`),
857
858
  `test_if(cond, name, fn)`
858
859
  for capability-gated cases, and `in_process_capabilities` preset. `cell_crud`
859
860
  gates the CRUD parity suite, `cell_relations` the relation / ACL / audit
@@ -866,7 +867,10 @@ source of truth for wire-shape conformance.
866
867
  gates `describe_fact_serving_cross_tests` (the cell-scoped per-reference +
867
868
  admin-only bare-hash fact-serving parity suite); like cells it stays off the
868
869
  declared surface and is `true` on every spine that mounts the serve routes +
869
- the `_testing_put_fact` seeder.
870
+ the `_testing_put_fact` seeder. `ready` gates `describe_ready_cross_tests`
871
+ (anonymous `GET /ready` → `200 {ready: true}` on a clean spine bootstrap);
872
+ like cells/sse the `/ready` deploy gate stays off the declared surface, `true`
873
+ on every spine that live-mounts it over the shared `expected_schema.json`.
870
874
 
871
875
  ### `cross_backend/standard.ts` — `describe_standard_cross_process_tests`
872
876
 
@@ -1071,8 +1075,8 @@ in-process legs (plain `gro test`) are `src/test/auth/cell_crud_parity.db.test.t
1071
1075
  - `testing/cross_backend/backend_config.ts` — `BackendConfig` +
1072
1076
  `BackendBootstrapConfig` interfaces. Consumer factories
1073
1077
  (`deno_backend_config()`, `rust_backend_config()`,
1074
- `spine_stub_backend_config()`) produce these; fuz_app ships
1075
- `spine_stub_backend_config()` as a convenience preset for the non-domain
1078
+ `rust_spine_stub_backend_config()`) produce these; fuz_app ships
1079
+ `rust_spine_stub_backend_config()` as a convenience preset for the non-domain
1076
1080
  third spine consumer, but otherwise backend-specific paths and env are a
1077
1081
  consumer concern.
1078
1082
  - `testing/cross_backend/spawn_backend.ts` — `spawn_backend(config) => BackendHandle`.
@@ -1180,6 +1184,31 @@ in-process `auth/origin_parity.db.test.ts` + the cross-process
1180
1184
  Rust spine returned a plain-text body — now converged to the canonical TS
1181
1185
  `{error: "forbidden_origin"}` via `fuz_http::forbidden_origin_response()`.
1182
1186
 
1187
+ ### Readiness probe parity — `cross_backend/ready.ts`
1188
+
1189
+ `describe_ready_cross_tests({setup_test, capabilities, ready_path?})` — the
1190
+ imperative `/ready` deploy-gate suite: an anonymous, cookie-jar-free,
1191
+ no-Origin `GET /ready` → `200 {ready: true}` on a clean spine bootstrap (the
1192
+ deploy-poll shape a gate like zap uses). The `/ready` mechanism + its
1193
+ drift → `503` path are per-impl unit tests already (TS `db/schema_ready.ts`,
1194
+ Rust `fuz_db::schema_ready` / `fuz_http::ready`); this is the cross-impl
1195
+ success-path gate. Gated on `capabilities.ready`. Imperative (not a
1196
+ `conformance_table` row) because `/ready` is a public flat-REST route, not a
1197
+ JSON-RPC envelope, and the probe needs `fresh_transport({origin: null})` —
1198
+ the same reasons the sibling `cross_backend/origin.ts` suite is imperative.
1199
+ `$lib`-free; runs both legs (`cross_backend/ready_parity.db.test.ts` +
1200
+ `cross_backend/ready.cross.test.ts`).
1201
+
1202
+ Both backends read the **same** committed
1203
+ `testing/cross_backend/expected_schema.json` (column-presence is engine-portable,
1204
+ so one fixture is the cross-impl contract): the TS spine via
1205
+ `create_spine_ready_route_spec` (an `import.meta.url` URL off `default_spine_surface.ts`),
1206
+ the Rust `testing_spine_stub` via the absolute path `rust_spine_stub_backend_config`
1207
+ passes through `FUZ_RUST_SPINE_STUB_EXPECTED_SCHEMA_PATH`. The fixture covers the full
1208
+ spine bootstrap (auth + cell + cell_history + fact) and is regenerated +
1209
+ drift-guarded by `src/test/cross_backend/spine_expected_schema.db.test.ts`
1210
+ (`UPDATE_SCHEMA_READY=1`, then `gro format`).
1211
+
1183
1212
  ### Building a TS test-server binary — `testing_server_core.ts` + adapters
1184
1213
 
1185
1214
  The reusable shape for standing up a **spawnable TS** cross-process test
@@ -1191,7 +1220,7 @@ re-roll the serve / daemon-info / WS-attach / drain boilerplate:
1191
1220
  - `testing/cross_backend/testing_server_deno.ts` — `create_deno_testing_adapter()` (`Deno.serve` + `hono/deno`; `Deno` declared locally so it typechecks under the Node toolchain). Spawn the entry with `--sloppy-imports` (Deno doesn't do `.js`→`.ts`; Gro's loader does, so the Node path needs no flag).
1192
1221
  - `testing/cross_backend/testing_server_bun.ts` — `create_bun_testing_adapter()` (`Bun.serve` + `hono/bun`'s module-level `upgradeWebSocket` + `websocket`; `Bun.serve` declared locally so it typechecks under the Node toolchain). **No extra deps** (`hono/bun` ships with `hono`; `Bun.serve` is built in, unlike Node's `@hono/node-server` + `@hono/node-ws`), and Bun resolves `.js`→`.ts` natively (no flag, unlike Deno). Reuses `create_node_runtime` (Bun implements the `node:fs`/`node:process` surface). WS is module-level + stateless (like Deno) — the `websocket` handler is threaded into `serve`, where `Bun.serve` wants it, so no post-serve attach.
1193
1222
  - `testing/cross_backend/default_spine_surface.ts` — the canonical no-domain spine surface (account/admin/audit/signup + bootstrap): `spine_session_options`, `spine_roles`, `create_spine_route_specs`, `spine_rpc_endpoints`, `create_spine_surface_spec`. `$lib`-free (it's reached by the spawned binary under Gro's loader, which doesn't resolve `$lib`), so keep it on relative imports. Shared by the spine_stub cross test, the TS cross tests, and the binary.
1194
- - `testing/cross_backend/ts_spine_backend_config.ts` — `ts_spine_node_backend_config()` / `ts_spine_deno_backend_config()` / `ts_spine_bun_backend_config()` presets (in-memory PGlite, no external infra), the TS analog of `spine_stub_backend_config()`.
1223
+ - `testing/cross_backend/ts_spine_backend_config.ts` — `ts_spine_node_backend_config()` / `ts_spine_deno_backend_config()` / `ts_spine_bun_backend_config()` presets (in-memory PGlite, no external infra), the TS analog of `rust_spine_stub_backend_config()`.
1195
1224
 
1196
1225
  fuz_app's own binary wiring (`src/test/cross_backend/testing_spine_server{,_node,_deno,_bun}.ts`) is the worked example: ~one `build_app` over `create_app_backend` + `create_app_server` + `_testing_reset` + a WS mount, reusing `default_spine_surface`. The `_node`/`_deno`/`_bun` entries differ only in which adapter they wire — `build_spine_app` is runtime-agnostic.
1197
1226
 
@@ -1234,7 +1263,7 @@ no stats engine reinvented. fuz_app ships the primitive; consumers wire
1234
1263
  scenarios + the run (zzz's `npm run benchmark:cross-impl` was the first).
1235
1264
  fuz_app also ships its **own** `npm run benchmark:cross-impl`
1236
1265
  (`src/benchmarks/cross_impl.bench.ts`) on the back of its TS spine binary —
1237
- ts-node + ts-deno + ts-bun (+ the Rust `spine_stub` when `FUZ_TESTING_SPINE_STUB_BIN`
1266
+ ts-node + ts-deno + ts-bun (+ the Rust `spine_stub` when `FUZ_TESTING_RUST_SPINE_STUB_BIN`
1238
1267
  is set). The three TS runtimes are apples-to-apples with each other (same
1239
1268
  PGlite driver); TS-vs-Rust carries the PGlite-vs-Postgres DB-layer caveat
1240
1269
  (documented in the run). The artifact (`*.latest.json`) is gitignored.
@@ -6,10 +6,10 @@ import '../assert_dev_env.js';
6
6
  * env vars, bootstrap credentials, daemon-token discovery path, declared
7
7
  * capabilities. Consumer projects ship per-backend factories
8
8
  * (`deno_backend_config()`, `rust_backend_config()`,
9
- * `spine_stub_backend_config()`) that produce this shape; `spawn_backend`
9
+ * `rust_spine_stub_backend_config()`) that produce this shape; `spawn_backend`
10
10
  * consumes it.
11
11
  *
12
- * fuz_app ships `spine_stub_backend_config()` as a convenience preset
12
+ * fuz_app ships `rust_spine_stub_backend_config()` as a convenience preset
13
13
  * (operational dep on `testing_spine_stub` — path-based discovery, no
14
14
  * `package.json` coupling to the stub's source package). Otherwise backend-specific
15
15
  * knowledge (binary paths, port choices, env vars) is a consumer