@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.
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +19 -9
- package/dist/auth/migrations.d.ts +21 -18
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +22 -19
- package/dist/db/CLAUDE.md +6 -0
- package/dist/db/schema_ready.d.ts +83 -0
- package/dist/db/schema_ready.d.ts.map +1 -0
- package/dist/db/schema_ready.js +103 -0
- package/dist/http/CLAUDE.md +3 -2
- package/dist/http/common_routes.d.ts +49 -0
- package/dist/http/common_routes.d.ts.map +1 -1
- package/dist/http/common_routes.js +92 -0
- package/dist/testing/CLAUDE.md +35 -6
- package/dist/testing/cross_backend/backend_config.d.ts +2 -2
- package/dist/testing/cross_backend/capabilities.d.ts +10 -0
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +6 -0
- package/dist/testing/cross_backend/default_spine_surface.d.ts +48 -0
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +24 -24
- package/dist/testing/cross_backend/expected_schema.json +113 -0
- package/dist/testing/cross_backend/ready.d.ts +14 -0
- package/dist/testing/cross_backend/ready.d.ts.map +1 -0
- package/dist/testing/cross_backend/ready.js +50 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +39 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +103 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +6 -2
- package/dist/testing/schema_ready_fixture.d.ts +46 -0
- package/dist/testing/schema_ready_fixture.d.ts.map +1 -0
- package/dist/testing/schema_ready_fixture.js +48 -0
- package/package.json +22 -15
- package/dist/testing/cross_backend/spine_stub_backend_config.d.ts +0 -66
- package/dist/testing/cross_backend/spine_stub_backend_config.d.ts.map +0 -1
- 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":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAEnD,OAAO,EAEN,KAAK,OAAO,EACZ,KAAK,KAAK,EACV,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,MAAM,qBAAqB,CAAC;
|
|
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
|
|
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
|
|
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
|
|
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
|
-
* **
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 frozen — every 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
|
|
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"}
|
package/dist/auth/migrations.js
CHANGED
|
@@ -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
|
-
* **
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 frozen — every 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 —
|
|
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
|
+
};
|
package/dist/http/CLAUDE.md
CHANGED
|
@@ -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
|
|
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;
|
|
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
|
*
|