@fuzdev/fuz_app 0.82.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 (40) hide show
  1. package/dist/auth/account_queries.d.ts.map +1 -1
  2. package/dist/auth/account_queries.js +19 -9
  3. package/dist/auth/migrations.d.ts +21 -18
  4. package/dist/auth/migrations.d.ts.map +1 -1
  5. package/dist/auth/migrations.js +22 -19
  6. package/dist/db/CLAUDE.md +6 -0
  7. package/dist/db/schema_ready.d.ts +83 -0
  8. package/dist/db/schema_ready.d.ts.map +1 -0
  9. package/dist/db/schema_ready.js +103 -0
  10. package/dist/http/CLAUDE.md +3 -2
  11. package/dist/http/common_routes.d.ts +49 -0
  12. package/dist/http/common_routes.d.ts.map +1 -1
  13. package/dist/http/common_routes.js +92 -0
  14. package/dist/testing/CLAUDE.md +35 -6
  15. package/dist/testing/cross_backend/backend_config.d.ts +2 -2
  16. package/dist/testing/cross_backend/capabilities.d.ts +10 -0
  17. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  18. package/dist/testing/cross_backend/capabilities.js +1 -0
  19. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  20. package/dist/testing/cross_backend/default_backend_configs.js +6 -0
  21. package/dist/testing/cross_backend/default_spine_surface.d.ts +48 -0
  22. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  23. package/dist/testing/cross_backend/default_spine_surface.js +24 -24
  24. package/dist/testing/cross_backend/expected_schema.json +113 -0
  25. package/dist/testing/cross_backend/ready.d.ts +14 -0
  26. package/dist/testing/cross_backend/ready.d.ts.map +1 -0
  27. package/dist/testing/cross_backend/ready.js +50 -0
  28. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +39 -0
  29. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -0
  30. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +103 -0
  31. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +1 -1
  32. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  33. package/dist/testing/cross_backend/ts_spine_backend_config.js +6 -2
  34. package/dist/testing/schema_ready_fixture.d.ts +46 -0
  35. package/dist/testing/schema_ready_fixture.d.ts.map +1 -0
  36. package/dist/testing/schema_ready_fixture.js +48 -0
  37. package/package.json +22 -15
  38. package/dist/testing/cross_backend/spine_stub_backend_config.d.ts +0 -66
  39. package/dist/testing/cross_backend/spine_stub_backend_config.d.ts.map +0 -1
  40. package/dist/testing/cross_backend/spine_stub_backend_config.js +0 -53
@@ -1 +1 @@
1
- {"version":3,"file":"account_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_queries.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"account_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_queries.ts"],"names":[],"mappings":"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"}
@@ -9,6 +9,22 @@
9
9
  import { assert_row } from '../db/assert_row.js';
10
10
  import { to_admin_account, } from './account_schema.js';
11
11
  import { ADMIN_ACCOUNT_LIST_DEFAULT_LIMIT } from './admin_action_specs.js';
12
+ /**
13
+ * The full `account` column set, named explicitly so a row read fails loud
14
+ * on schema drift.
15
+ *
16
+ * `SELECT *` silently omits a dropped column, which the login lookups then
17
+ * misread: `query_account_by_username_or_email` filters its result with
18
+ * `account.deleted_at === null`, so a missing `deleted_at` column reads back
19
+ * as `undefined`, `undefined === null` is `false`, and *every* login resolves
20
+ * to "not found" (401) — a silent, total auth outage instead of an error.
21
+ * Selecting named columns turns that drift into a hard Postgres
22
+ * `column "..." does not exist`. Mirrors the Rust side
23
+ * (`fuz_auth/src/account_queries.rs`), which selects named columns and
24
+ * decodes them positionally. Keep in sync with `Account` and the `account`
25
+ * DDL in `auth/auth_ddl.ts`.
26
+ */
27
+ const ACCOUNT_COLUMNS = 'id, username, email, email_verified, password_hash, created_at, created_by, updated_at, updated_by, deleted_at, deleted_by';
12
28
  /**
13
29
  * Create a new account.
14
30
  *
@@ -33,25 +49,19 @@ export const query_create_account = async (deps, input) => {
33
49
  * soft-deleted rows too, uses `query_purge_account` directly.
34
50
  */
35
51
  export const query_account_by_id = async (deps, id) => {
36
- return deps.db.query_one(`SELECT * FROM account WHERE id = $1 AND deleted_at IS NULL`, [
37
- id,
38
- ]);
52
+ return deps.db.query_one(`SELECT ${ACCOUNT_COLUMNS} FROM account WHERE id = $1 AND deleted_at IS NULL`, [id]);
39
53
  };
40
54
  /**
41
55
  * Find an account by username (case-insensitive).
42
56
  */
43
57
  export const query_account_by_username = async (deps, username) => {
44
- return deps.db.query_one(`SELECT * FROM account WHERE LOWER(username) = LOWER($1)`, [
45
- username,
46
- ]);
58
+ return deps.db.query_one(`SELECT ${ACCOUNT_COLUMNS} FROM account WHERE LOWER(username) = LOWER($1)`, [username]);
47
59
  };
48
60
  /**
49
61
  * Find an account by email (case-insensitive).
50
62
  */
51
63
  export const query_account_by_email = async (deps, email) => {
52
- return deps.db.query_one(`SELECT * FROM account WHERE LOWER(email) = LOWER($1)`, [
53
- email,
54
- ]);
64
+ return deps.db.query_one(`SELECT ${ACCOUNT_COLUMNS} FROM account WHERE LOWER(email) = LOWER($1)`, [email]);
55
65
  };
56
66
  /**
57
67
  * Find an account by username or email.
@@ -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
  *