@ultimat3/db 29.4.0 → 30.1.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 (96) hide show
  1. package/CLAUDE.md +74 -110
  2. package/README.md +163 -15
  3. package/package.json +13 -2
  4. package/src/app-relation.ts +7 -2
  5. package/src/catalog-fold.ts +5 -0
  6. package/src/catalog-objects.ts +20 -24
  7. package/src/catalog-policies.ts +81 -0
  8. package/src/catalog-relations.ts +31 -10
  9. package/src/catalog.ts +40 -1
  10. package/src/check-ddl.ts +34 -7
  11. package/src/client.ts +132 -48
  12. package/src/column-alter.ts +42 -1
  13. package/src/concurrent-index.ts +20 -0
  14. package/src/concurrent-plan.ts +135 -17
  15. package/src/connection-url.ts +16 -4
  16. package/src/db-diagnostics-deadline.ts +43 -0
  17. package/src/db-diagnostics-redact.ts +58 -0
  18. package/src/db-diagnostics-rows.ts +166 -0
  19. package/src/db-diagnostics-sql.ts +92 -0
  20. package/src/db-diagnostics.ts +178 -0
  21. package/src/destructive.ts +17 -3
  22. package/src/diagnostics-entry.ts +15 -0
  23. package/src/drift-errors.ts +9 -4
  24. package/src/drift-findings.ts +47 -62
  25. package/src/drift-missing.ts +203 -0
  26. package/src/drift-row-security.ts +88 -0
  27. package/src/drift-triggers.ts +54 -0
  28. package/src/drift.ts +88 -21
  29. package/src/entity-shape.ts +67 -0
  30. package/src/errors.ts +77 -9
  31. package/src/expected-loop.ts +34 -4
  32. package/src/failure-store-pg.ts +157 -0
  33. package/src/foreign-key-plan.ts +75 -23
  34. package/src/foreign-key.ts +76 -5
  35. package/src/generate-counter.ts +85 -0
  36. package/src/generate-history.ts +142 -0
  37. package/src/generate-row-security.ts +51 -0
  38. package/src/generate-snapshot.ts +82 -0
  39. package/src/generate-steps.ts +6 -1
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +105 -79
  43. package/src/index.ts +129 -6
  44. package/src/introspect-catalog.ts +26 -6
  45. package/src/introspect-rows.ts +82 -0
  46. package/src/introspect.ts +108 -50
  47. package/src/keyset.ts +182 -0
  48. package/src/lifecycle-errors.ts +15 -0
  49. package/src/local-settings.ts +38 -0
  50. package/src/marker-line.ts +12 -4
  51. package/src/migrate-lock-retry.ts +60 -0
  52. package/src/migrate-rollback.ts +45 -6
  53. package/src/migrate.ts +45 -4
  54. package/src/migration-errors.ts +27 -1
  55. package/src/migration-ledger.ts +38 -4
  56. package/src/migration-step.ts +50 -22
  57. package/src/partition-order.ts +56 -0
  58. package/src/partitioned-tables.ts +24 -0
  59. package/src/pool-gate.ts +120 -0
  60. package/src/pool-listeners.ts +143 -0
  61. package/src/pool-pins.ts +68 -0
  62. package/src/pool-profile.ts +23 -7
  63. package/src/pool-reserve.ts +11 -4
  64. package/src/pool-retire.ts +22 -0
  65. package/src/pool-sweep.ts +115 -0
  66. package/src/pool-timer.ts +10 -0
  67. package/src/pooler-fix.ts +53 -0
  68. package/src/pooler.ts +161 -0
  69. package/src/read-transaction.ts +59 -0
  70. package/src/readonly-query.ts +4 -4
  71. package/src/replica-client.ts +54 -22
  72. package/src/replica-position.ts +89 -0
  73. package/src/replica-scope.ts +25 -4
  74. package/src/retired.ts +131 -0
  75. package/src/retype-dependents.ts +2 -0
  76. package/src/rolling-errors.ts +97 -0
  77. package/src/rolling.ts +212 -0
  78. package/src/row-secure-client.ts +54 -0
  79. package/src/row-security-catalog.ts +26 -0
  80. package/src/row-security-migrator.ts +48 -0
  81. package/src/row-security-plan.ts +58 -0
  82. package/src/row-security-role.ts +60 -0
  83. package/src/row-security-roles.ts +64 -0
  84. package/src/row-security.ts +59 -0
  85. package/src/same-tenant.ts +173 -0
  86. package/src/schema-dump-policies.ts +26 -0
  87. package/src/schema-dump-table.ts +21 -6
  88. package/src/schema-dump.ts +13 -2
  89. package/src/snapshot-parse.ts +42 -2
  90. package/src/sqlstate.ts +4 -0
  91. package/src/statement-bound.ts +82 -0
  92. package/src/transaction-errors.ts +14 -0
  93. package/src/transaction-guard.ts +40 -0
  94. package/src/transaction-settings.ts +24 -0
  95. package/src/transaction.ts +74 -19
  96. package/src/ungeneratable.ts +30 -0
@@ -0,0 +1,60 @@
1
+ // Single responsibility: is the role this process serves as subject to row security at all.
2
+ // Postgres exempts a superuser, a `bypassrls` role, and a table's owner unless the table FORCES it;
3
+ // a policy over any of those refuses nothing. Read off `current_user`'s attributes, never a name.
4
+
5
+ import type { DbClient } from './client';
6
+ import { DbError } from './errors';
7
+ import { sql } from './sql';
8
+
9
+ export interface RowSecurityRole {
10
+ readonly role: string;
11
+ readonly superuser: boolean;
12
+ readonly bypassRls: boolean;
13
+ /** Tables this role owns with row security enabled and not forced: the owner sees every row. */
14
+ readonly ownedUnforced: number;
15
+ }
16
+
17
+ interface RoleRow {
18
+ readonly role: string;
19
+ readonly superuser: boolean;
20
+ readonly bypass: boolean;
21
+ readonly unforced: number;
22
+ }
23
+
24
+ /** The attributes, read once. `x doctor` lists them; the boot judges them. */
25
+ export async function rowSecurityRole(client: DbClient): Promise<RowSecurityRole> {
26
+ const row = await client.one<RoleRow>(sql`
27
+ select r.rolname as role, r.rolsuper as superuser, r.rolbypassrls as bypass,
28
+ (select count(*) from pg_class c
29
+ where c.relowner = r.oid and c.relrowsecurity and not c.relforcerowsecurity)::int as unforced
30
+ from pg_roles r where r.rolname = current_user`);
31
+ return {
32
+ role: row?.role ?? '',
33
+ superuser: row?.superuser === true,
34
+ bypassRls: row?.bypass === true,
35
+ ownedUnforced: Number(row?.unforced ?? 0),
36
+ };
37
+ }
38
+
39
+ /** Why `role` is exempt, or `undefined` when row security binds it. */
40
+ export function rowSecurityExemption(role: RowSecurityRole): string | undefined {
41
+ if (role.superuser) return 'is a superuser';
42
+ if (role.bypassRls) return 'has bypassrls';
43
+ if (role.ownedUnforced > 0) {
44
+ return `owns ${String(role.ownedUnforced)} table(s) with row security enabled and not forced`;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /** `X_DB_ROW_SECURITY_BYPASSED` when the serving role would see every tenant's rows. */
50
+ export async function assertRowSecurityBinds(client: DbClient): Promise<void> {
51
+ const role = await rowSecurityRole(client);
52
+ const exemption = rowSecurityExemption(role);
53
+ if (exemption === undefined) return;
54
+ throw new DbError({
55
+ code: 'X_DB_ROW_SECURITY_BYPASSED',
56
+ cause: `database.rowSecurity is on and the connected role ${JSON.stringify(role.role)} ${exemption}, which Postgres exempts from every policy`,
57
+ fix: 'psql "$DATABASE_URL" -c \'alter role <role> nosuperuser nobypassrls\' # and serve as a role that owns no table: the migrator owns them, ROLE=migrate',
58
+ meta: { role: role.role, superuser: role.superuser, bypassRls: role.bypassRls },
59
+ });
60
+ }
@@ -0,0 +1,64 @@
1
+ // Single responsibility: the two roles row security needs on an engine whose own user is a
2
+ // superuser — PGlite's default role, and the test services' `ultimate`. The app role serves and is
3
+ // subject to every policy; the migrator owns the tables and bypasses them, so a backfill or a key
4
+ // validate still sees every row. Idempotent: run on every boot of a flag-on database.
5
+
6
+ import type { DbClient } from './client';
7
+ import { identifier, raw } from './sql';
8
+
9
+ /** `nosuperuser nobypassrls`, owning nothing: the role a request runs as. */
10
+ export const APP_ROLE = 'x_app';
11
+ /** `bypassrls`, owning every table: the role `ROLE=migrate` runs as. */
12
+ export const MIGRATOR_ROLE = 'x_migrator';
13
+
14
+ /**
15
+ * The statements, in order. Created only when absent (`create role` has no `if not exists`),
16
+ * attributes re-asserted every time so a role an operator altered by hand is put back. The
17
+ * bootstrap user may switch into both. Text, so a runner with no `DbClient` (`@ultimat3/testing`'s
18
+ * template database) runs the same list.
19
+ */
20
+ export function rowSecurityRolesSql(): readonly string[] {
21
+ const app = identifier(APP_ROLE).text;
22
+ const migrator = identifier(MIGRATOR_ROLE).text;
23
+ const later = (grant: string): string =>
24
+ `alter default privileges for role ${migrator} in schema public grant ${grant} to ${app}`;
25
+ return [
26
+ 'do $roles$ begin ' +
27
+ `if not exists (select from pg_roles where rolname = '${APP_ROLE}') then create role ${app}; end if; ` +
28
+ `if not exists (select from pg_roles where rolname = '${MIGRATOR_ROLE}') then create role ${migrator}; end if; ` +
29
+ 'end $roles$',
30
+ `alter role ${app} nosuperuser nobypassrls nocreaterole nocreatedb`,
31
+ `alter role ${migrator} nosuperuser bypassrls`,
32
+ `grant ${app}, ${migrator} to current_user`,
33
+ // What the migrator creates later, the app role can reach: the grants follow the table.
34
+ `grant usage on schema public to ${app}`,
35
+ `grant create, usage on schema public to ${migrator}`,
36
+ later('select, insert, update, delete on tables'),
37
+ later('usage, select on sequences'),
38
+ ];
39
+ }
40
+
41
+ /** Idempotent: run on every boot of a flag-on database. */
42
+ export async function ensureRowSecurityRoles(client: DbClient): Promise<void> {
43
+ for (const statement of rowSecurityRolesSql()) await client.execute(raw(statement));
44
+ }
45
+
46
+ /** `set role` on a session — PGlite's one, or a test connection — for the rest of its life. */
47
+ export function assumeRole(client: DbClient, role: typeof APP_ROLE | typeof MIGRATOR_ROLE) {
48
+ return client.execute(raw(`set role ${identifier(role).text}`));
49
+ }
50
+
51
+ /**
52
+ * On an engine whose roles are ours (PGlite, whose one session is a superuser): the roles, DML on
53
+ * every table and sequence already there — the framework's `x_*` and any table a superuser created
54
+ * before the flag — then the session serves as `x_app` for the rest of its life. Idempotent.
55
+ */
56
+ export async function serveAsAppRole(client: DbClient): Promise<void> {
57
+ const app = identifier(APP_ROLE).text;
58
+ await ensureRowSecurityRoles(client);
59
+ await client.execute(
60
+ raw(`grant select, insert, update, delete on all tables in schema public to ${app}`),
61
+ );
62
+ await client.execute(raw(`grant usage, select on all sequences in schema public to ${app}`));
63
+ await assumeRole(client, APP_ROLE);
64
+ }
@@ -0,0 +1,59 @@
1
+ // Single responsibility: the SQL of the database's own tenant guard — enable and FORCE row level
2
+ // security on a tenant table, one policy reading the transaction-local `x.tenant` / `x.cross`, and
3
+ // the drop. Pure text: the generator emits it, the drift repair pastes it, the live test runs it.
4
+
5
+ import { identifierUnsafe } from './errors';
6
+ import { identifier } from './sql';
7
+
8
+ /** The one policy name, the same on every table: a policy name is unique per table. */
9
+ export const ROW_SECURITY_POLICY = 'ultimate_tenant';
10
+ /** The settings `setLocal` writes and the policy reads. Never set at session level. */
11
+ export const TENANT_SETTING = 'x.tenant';
12
+ export const CROSS_TENANT_SETTING = 'x.cross';
13
+
14
+ /**
15
+ * A Postgres type name as an entity column records it (`uuid`, `text`, `bigint`, `integer`). A
16
+ * closed shape, re-derived rather than spliced: `create policy` takes no parameter, so a type
17
+ * reaching it is text, and anything but a plain (optionally spaced) word is refused upstream.
18
+ */
19
+ const TYPE = /^[a-z][a-z0-9 ]*$/;
20
+
21
+ /**
22
+ * Unset is `''` (a `set_config` local value reads back as `''` once its transaction ends) and
23
+ * `nullif` turns it into NULL, so an unset tenant matches NO row: fail closed, never open.
24
+ */
25
+ function predicate(column: string, type: string): string {
26
+ if (!TYPE.test(type)) throw identifierUnsafe(type);
27
+ return (
28
+ `(${identifier(column).text} = nullif(current_setting('${TENANT_SETTING}', true), '')::${type}` +
29
+ ` or current_setting('${CROSS_TENANT_SETTING}', true) = 'on')`
30
+ );
31
+ }
32
+
33
+ /**
34
+ * `enable` + `force` (an owner is exempt unless forced) + the policy, `using` for what a statement
35
+ * may see and `with check` for what it may write: an insert naming another tenant is refused.
36
+ */
37
+ export function enableRowSecuritySql(
38
+ table: string,
39
+ column: string,
40
+ type: string,
41
+ ): readonly string[] {
42
+ const name = identifier(table).text;
43
+ const rule = predicate(column, type);
44
+ return [
45
+ `alter table ${name} enable row level security;`,
46
+ `alter table ${name} force row level security;`,
47
+ `create policy ${identifier(ROW_SECURITY_POLICY).text} on ${name} using ${rule} with check ${rule};`,
48
+ ];
49
+ }
50
+
51
+ /** The revert. `if exists`: a database where the policy was already dropped by hand must not fail. */
52
+ export function dropRowSecuritySql(table: string): readonly string[] {
53
+ const name = identifier(table).text;
54
+ return [
55
+ `drop policy if exists ${identifier(ROW_SECURITY_POLICY).text} on ${name};`,
56
+ `alter table ${name} no force row level security;`,
57
+ `alter table ${name} disable row level security;`,
58
+ ];
59
+ }
@@ -0,0 +1,173 @@
1
+ // Single responsibility: what a same-tenant edge needs that its own entity cannot declare. The
2
+ // composite key names `(tenant, id)` on the TARGET, so the unique index behind it belongs to a table
3
+ // whose description knows nothing about who points at it. It is answered once, over the whole set.
4
+
5
+ import type {
6
+ ColumnDescriptionLike,
7
+ EntityDescriptionLike,
8
+ IndexDescriptionLike,
9
+ } from './entity-shape';
10
+ import { referenceParts } from './foreign-key';
11
+ import { findTable, type IndexDescription, type SchemaDescription } from './introspect';
12
+ import { MAX_IDENTIFIER_BYTES } from './invariant-ddl';
13
+
14
+ /** How much of a name survives beside its hash: `_` and eight hex characters. */
15
+ const KEPT_BYTES = MAX_IDENTIFIER_BYTES - 9;
16
+
17
+ /**
18
+ * Within the 63 bytes Postgres truncates at and says nothing. Past them two keys sharing a prefix
19
+ * would be one name on the server, so the end is a hash of the whole: deterministic across
20
+ * processes, because a name is a property of the declaration. Every name a same-tenant edge mints
21
+ * passes here, and a name that fits is untouched.
22
+ */
23
+ const bounded = (name: string): string => {
24
+ if (new TextEncoder().encode(name).length <= MAX_IDENTIFIER_BYTES) return name;
25
+ const hash = new Bun.CryptoHasher('sha256').update(name).digest('hex').slice(0, 8);
26
+ return `${name.slice(0, KEPT_BYTES)}_${hash}`;
27
+ };
28
+
29
+ /** `<table>_<column>_tenant_fkey`: another name than the plain key's, so the two can coexist. */
30
+ export function sameTenantKeyName(table: string, column: string): string {
31
+ return bounded(`${table}_${column}_tenant_fkey`);
32
+ }
33
+
34
+ /** Whether an index already holds `columns` unique over every row, whichever way round. */
35
+ const answers = (index: IndexDescriptionLike, columns: ReadonlySet<string>): boolean =>
36
+ index.unique &&
37
+ index.where === null &&
38
+ index.columns.length === columns.size &&
39
+ index.columns.every((column) => columns.has(column));
40
+
41
+ /** The same question of a `unique` invariant: `declaredIndexes` makes it an index of its own. */
42
+ const invariantAnswers = (entity: EntityDescriptionLike, columns: ReadonlySet<string>): boolean =>
43
+ (entity.invariants ?? []).some(
44
+ ({ kind, where, columns: named }) =>
45
+ kind === 'unique' &&
46
+ where === null &&
47
+ named?.length === columns.size &&
48
+ named.every((column) => columns.has(column)),
49
+ );
50
+
51
+ const primaryKeyAnswers = (entity: EntityDescriptionLike, columns: ReadonlySet<string>): boolean =>
52
+ entity.primaryKey.length === columns.size &&
53
+ entity.primaryKey.every((column) => columns.has(column));
54
+
55
+ const sameTenantColumns = (
56
+ entity: EntityDescriptionLike,
57
+ ): readonly (ColumnDescriptionLike & { readonly references: string })[] =>
58
+ entity.columns.flatMap((column) =>
59
+ column.references !== null && column.references !== undefined && column.sameTenant != null
60
+ ? [{ ...column, references: column.references }]
61
+ : [],
62
+ );
63
+
64
+ /** Every target's wanted column lists, keyed by target table, then by the list itself. */
65
+ type Wanted = Map<string, Map<string, readonly string[]>>;
66
+
67
+ const want = (wanted: Wanted, target: string, columns: readonly string[]): void => {
68
+ const forTarget = wanted.get(target) ?? new Map<string, readonly string[]>();
69
+ forTarget.set(columns.join('\0'), columns);
70
+ wanted.set(target, forTarget);
71
+ };
72
+
73
+ /**
74
+ * What a retired column's recorded composite key still points at. The column left the description
75
+ * and stays in the table, its key with it, so the unique index under that key cannot go: a drop is
76
+ * `2BP01`, on every run. It goes once the column does (`drop column` takes the key first).
77
+ */
78
+ const wantedByRetired = (
79
+ entities: readonly EntityDescriptionLike[],
80
+ current: SchemaDescription | undefined,
81
+ wanted: Wanted,
82
+ ): void => {
83
+ if (current === undefined) return;
84
+ for (const entity of entities) {
85
+ const retired = entity.retired ?? [];
86
+ if (retired.length === 0) continue;
87
+ for (const key of findTable(current, entity.table)?.foreignKeys ?? []) {
88
+ if (key.columns.length > 1 && key.columns.some((column) => retired.includes(column))) {
89
+ want(wanted, key.referencedTable, key.referencedColumns);
90
+ }
91
+ }
92
+ }
93
+ };
94
+
95
+ /**
96
+ * The set with every target's `(tenant, column)` unique index on its own list. `concurrently: true`
97
+ * means what it means on any index: nothing on a table this migration creates, the deferred step on
98
+ * a live one, which is what `targetIndexDeferred` reads so the key lands after it.
99
+ *
100
+ * Skipped when the table already holds that uniqueness over every row. A PARTIAL one does not count:
101
+ * Postgres refuses a key over it (`42830`). Idempotent, so every entry point may pass through here.
102
+ * `current` is the record a retired column's key is still on; absent, only declared edges count.
103
+ */
104
+ export function withSameTenantIndexes(
105
+ entities: readonly EntityDescriptionLike[],
106
+ current?: SchemaDescription,
107
+ ): readonly EntityDescriptionLike[] {
108
+ const wanted: Wanted = new Map();
109
+ for (const entity of entities) {
110
+ for (const column of sameTenantColumns(entity)) {
111
+ const [target, key] = referenceParts(column.references);
112
+ want(wanted, target, [column.sameTenant?.targetColumn ?? '', key]);
113
+ }
114
+ }
115
+ wantedByRetired(entities, current, wanted);
116
+ if (wanted.size === 0) return entities;
117
+ return entities.map((entity) => {
118
+ const needed = wanted.get(entity.table);
119
+ if (needed === undefined) return entity;
120
+ const extra = [...needed.values()].flatMap((columns): IndexDescriptionLike[] => {
121
+ const set = new Set(columns);
122
+ if (
123
+ primaryKeyAnswers(entity, set) ||
124
+ invariantAnswers(entity, set) ||
125
+ entity.indexes.some((index) => answers(index, set))
126
+ ) {
127
+ return [];
128
+ }
129
+ return [
130
+ {
131
+ name: bounded(`${entity.table}_${columns.join('_')}_tenant_key`),
132
+ columns,
133
+ unique: true,
134
+ where: null,
135
+ order: null,
136
+ concurrently: true,
137
+ },
138
+ ];
139
+ });
140
+ return extra.length === 0 ? entity : { ...entity, indexes: [...entity.indexes, ...extra] };
141
+ });
142
+ }
143
+
144
+ /**
145
+ * The indexes only a retired column's recorded key keeps, per table, as a snapshot records them:
146
+ * for `recordRetired`, which is also `checkSnapshotDrift`'s path and has no migration to read them off.
147
+ */
148
+ export function retiredKeyIndexes(
149
+ entities: readonly EntityDescriptionLike[],
150
+ current: SchemaDescription,
151
+ ): ReadonlyMap<string, readonly IndexDescription[]> {
152
+ const out = new Map<string, readonly IndexDescription[]>();
153
+ const without = withSameTenantIndexes(entities);
154
+ for (const entity of withSameTenantIndexes(entities, current)) {
155
+ const known = new Set(
156
+ without.find((each) => each.table === entity.table)?.indexes.map((index) => index.name),
157
+ );
158
+ const kept = entity.indexes.filter((index) => !known.has(index.name));
159
+ if (kept.length === 0) continue;
160
+ out.set(
161
+ entity.table,
162
+ kept.map((index) => ({
163
+ name: index.name,
164
+ columns: [...index.columns],
165
+ unique: index.unique,
166
+ primary: false,
167
+ where: index.where,
168
+ order: index.order,
169
+ })),
170
+ );
171
+ }
172
+ return out;
173
+ }
@@ -0,0 +1,26 @@
1
+ // Single responsibility: a table's row security as dump statements — the `enable` and `force`
2
+ // flags, then each policy with every clause spelled. Pure; `schema-dump.ts` files them under
3
+ // `10_policies/`, after every table, function and role-free object they could name.
4
+
5
+ import type { CatalogPolicy, CatalogRowSecurity } from './catalog';
6
+ import { quoted } from './schema-dump-table';
7
+
8
+ export const rowSecurityStatements = (entry: CatalogRowSecurity): readonly string[] => [
9
+ ...(entry.enabled ? [`alter table ${quoted(entry.table)} enable row level security;`] : []),
10
+ ...(entry.forced ? [`alter table ${quoted(entry.table)} force row level security;`] : []),
11
+ ];
12
+
13
+ /** `public` is a keyword here, never a quoted role: `to "public"` names a role called public. */
14
+ const grantee = (role: string): string => (role === 'public' ? role : quoted(role));
15
+
16
+ export function policyStatement(policy: CatalogPolicy): string {
17
+ const clauses = [
18
+ `create policy ${quoted(policy.name)} on ${quoted(policy.table)}`,
19
+ `as ${policy.permissive ? 'permissive' : 'restrictive'}`,
20
+ `for ${policy.command}`,
21
+ `to ${policy.roles.map(grantee).join(', ')}`,
22
+ ];
23
+ if (policy.using !== null) clauses.push(`using (${policy.using})`);
24
+ if (policy.withCheck !== null) clauses.push(`with check (${policy.withCheck})`);
25
+ return `${clauses.join(' ')};`;
26
+ }
@@ -48,20 +48,35 @@ function columnClause(column: CatalogColumn): string {
48
48
  return parts.join(' ');
49
49
  }
50
50
 
51
+ /**
52
+ * `create table`, or for a partition `create table … partition of <parent> [(constraints)] <bound>`
53
+ * — a partition's columns are its parent's, so only the constraints it holds of its own are
54
+ * spelled. `partition by` follows either on a partitioned table, Postgres' clause order.
55
+ */
56
+ function createTable(table: CatalogTable): string {
57
+ const name = quoted(table.name);
58
+ const head = table.unlogged ? 'create unlogged table' : 'create table';
59
+ const by = table.partitionBy === undefined ? '' : ` partition by ${table.partitionBy}`;
60
+ const options = table.options === null ? '' : ` with (${table.options})`;
61
+ const constraints = table.constraints.map((line) => ` ${constraintClause(line)}`);
62
+ if (table.partitionOf !== undefined) {
63
+ const { table: parent, bound } = table.partitionOf;
64
+ const body = constraints.length === 0 ? '' : ` (\n${constraints.join(',\n')}\n)`;
65
+ return `${head} ${name} partition of ${quoted(parent)}${body} ${bound}${by}${options};`;
66
+ }
67
+ const body = [...table.columns.map((column) => ` ${columnClause(column)}`), ...constraints];
68
+ return [`${head} ${name} (`, body.join(',\n'), `)${by}${options};`].join('\n');
69
+ }
70
+
51
71
  /**
52
72
  * The statements of one table, in the order they must run: a `serial` column's default names its
53
73
  * sequence, so the sequence comes first and is handed to the column only once the table exists.
54
74
  */
55
75
  export function tableStatements(table: CatalogTable): readonly string[] {
56
76
  const name = quoted(table.name);
57
- const body = [...table.columns.map(columnClause), ...table.constraints.map(constraintClause)].map(
58
- (line) => ` ${line}`,
59
- );
60
- const head = table.unlogged ? 'create unlogged table' : 'create table';
61
- const tail = table.options === null ? ');' : `) with (${table.options});`;
62
77
  const statements = [
63
78
  ...table.sequences.map(createSequence),
64
- [`${head} ${name} (`, body.join(',\n'), tail].join('\n'),
79
+ createTable(table),
65
80
  ...table.sequences.map(
66
81
  (sequence) =>
67
82
  `alter sequence ${quoted(sequence.name)} owned by ${name}.${quoted(sequence.column)};`,
@@ -5,6 +5,8 @@
5
5
 
6
6
  import type { CatalogDescription, CatalogType } from './catalog';
7
7
  import { FRAMEWORK_TABLE_PREFIX } from './drift';
8
+ import { inLoadOrder } from './partition-order';
9
+ import { policyStatement, rowSecurityStatements } from './schema-dump-policies';
8
10
  import { constraintClause, createSequence, quoted, tableStatements } from './schema-dump-table';
9
11
  import { literal } from './sql';
10
12
 
@@ -25,6 +27,7 @@ export const SCHEMA_DUMP_KINDS = [
25
27
  '07_views',
26
28
  '08_functions',
27
29
  '09_triggers',
30
+ '10_policies',
28
31
  ] as const;
29
32
 
30
33
  export type SchemaDumpKind = (typeof SCHEMA_DUMP_KINDS)[number];
@@ -108,8 +111,9 @@ function collect(catalog: CatalogDescription): Buckets {
108
111
  for (const sequence of catalog.sequences) {
109
112
  out.add('03_sequences', sequence.name, sequence.name, createSequence(sequence));
110
113
  }
111
- for (const table of catalog.tables) {
112
- out.add('04_tables', table.name, table.name, ...tableStatements(table));
114
+ // A partition rides in its root's file, after its parent (`partition-order.ts`).
115
+ for (const { table, file } of inLoadOrder(catalog.tables)) {
116
+ out.add('04_tables', file, file, ...tableStatements(table));
113
117
  }
114
118
  for (const view of catalog.views) {
115
119
  const head = view.materialized ? 'create materialized view' : 'create view';
@@ -144,6 +148,13 @@ function collect(catalog: CatalogDescription): Buckets {
144
148
  out.add('09_triggers', trigger.table, trigger.table, statement);
145
149
  }
146
150
  }
151
+ // Last: a policy may call any function and name any table above it. Flags before policies.
152
+ for (const entry of catalog.rowSecurity) {
153
+ out.add('10_policies', entry.table, entry.table, ...rowSecurityStatements(entry));
154
+ }
155
+ for (const policy of catalog.policies) {
156
+ out.add('10_policies', policy.table, policy.table, policyStatement(policy));
157
+ }
147
158
  return out;
148
159
  }
149
160
 
@@ -10,6 +10,8 @@ import type {
10
10
  IndexDescription,
11
11
  SchemaDescription,
12
12
  TableDescription,
13
+ TriggerRecord,
14
+ ViewDescription,
13
15
  } from './introspect';
14
16
 
15
17
  type Row = Record<string, unknown>;
@@ -29,7 +31,7 @@ const order = (value: unknown): value is 'asc' | 'desc' | null =>
29
31
 
30
32
  function column(value: unknown): ColumnDescription | undefined {
31
33
  if (!isRow(value)) return undefined;
32
- const { name, dataType, nullable, default: fallback, position, generated } = value;
34
+ const { name, dataType, nullable, default: fallback, position, generated, retired } = value;
33
35
  if (!str(name) || !str(dataType) || !bool(nullable) || !nullableStr(fallback)) return undefined;
34
36
  if (typeof position !== 'number') return undefined;
35
37
  // `generated` was recorded by `snapshotOf` and dropped HERE, silently, for as long as the field
@@ -37,6 +39,9 @@ function column(value: unknown): ColumnDescription | undefined {
37
39
  // so `retypeColumn` read every generated column as newly generated and rebuilt it on every
38
40
  // `x db gen`. Absent stays absent — an ordinary column must gain no key.
39
41
  if (!(generated === undefined || str(generated))) return undefined;
42
+ // Dropped here it would refuse the drop it exists to allow. `true` or nothing, `false` normalised
43
+ // away: a hand-edit meaning what absence means, the rule `appendOnly` states below.
44
+ if (!(retired === undefined || bool(retired))) return undefined;
40
45
  return {
41
46
  name,
42
47
  dataType,
@@ -44,6 +49,7 @@ function column(value: unknown): ColumnDescription | undefined {
44
49
  default: fallback,
45
50
  position,
46
51
  ...(generated === undefined ? {} : { generated }),
52
+ ...(retired === true ? { retired: true as const } : {}),
47
53
  };
48
54
  }
49
55
 
@@ -101,6 +107,12 @@ function all<T>(value: unknown, one: (item: unknown) => T | undefined): readonly
101
107
  return parsed;
102
108
  }
103
109
 
110
+ function trigger(value: unknown): TriggerRecord | undefined {
111
+ if (!isRow(value)) return undefined;
112
+ const { name, install } = value;
113
+ return str(name) && strings(install) ? { name, install } : undefined;
114
+ }
115
+
104
116
  function tableOf(value: unknown): TableDescription | undefined {
105
117
  if (!isRow(value)) return undefined;
106
118
  const { schema, name, primaryKey } = value;
@@ -121,9 +133,21 @@ function tableOf(value: unknown): TableDescription | undefined {
121
133
  // `appendOnly` by the same rule: `true` or nothing, `false` normalised away.
122
134
  const appendOnly = value['appendOnly'];
123
135
  if (!(appendOnly === undefined || bool(appendOnly))) return undefined;
136
+ // `rowSecurity` by the same rule once more.
137
+ const rowSecurity = value['rowSecurity'];
138
+ if (!(rowSecurity === undefined || bool(rowSecurity))) return undefined;
139
+ const retain = value['retain'];
140
+ if (!(retain === undefined || bool(retain))) return undefined;
141
+ // Absent is none recorded; anything present must be a list of `{ name, install }`.
142
+ const rawTriggers = value['triggers'];
143
+ const triggers = rawTriggers === undefined ? undefined : all(rawTriggers, trigger);
144
+ if (rawTriggers !== undefined && triggers === undefined) return undefined;
124
145
  const flags = {
146
+ ...(retain === true ? { retain: true as const } : {}),
147
+ ...(triggers === undefined ? {} : { triggers }),
125
148
  ...(identity === true ? { replicaIdentityFull: true as const } : {}),
126
149
  ...(appendOnly === true ? { appendOnly: true as const } : {}),
150
+ ...(rowSecurity === true ? { rowSecurity: true as const } : {}),
127
151
  };
128
152
  const raw = value['checks'];
129
153
  if (raw === undefined) {
@@ -145,5 +169,21 @@ function tableOf(value: unknown): TableDescription | undefined {
145
169
  export function parseSnapshot(value: unknown): SchemaDescription | undefined {
146
170
  if (!isRow(value)) return undefined;
147
171
  const tables = all(value['tables'], tableOf);
148
- return tables === undefined ? undefined : { tables };
172
+ if (tables === undefined) return undefined;
173
+ const recorded = value['views'];
174
+ if (recorded === undefined) return { tables };
175
+ const views = all(recorded, viewOf);
176
+ return views === undefined ? undefined : { tables, views };
177
+ }
178
+
179
+ /** One recorded view: a name, the flag, the statements — anything else is not a snapshot. */
180
+ function viewOf(value: unknown): ViewDescription | undefined {
181
+ if (!isRow(value)) return undefined;
182
+ const { name, materialized, create } = value;
183
+ if (typeof name !== 'string') return undefined;
184
+ if (!(materialized === undefined || materialized === true)) return undefined;
185
+ if (!Array.isArray(create) || !create.every((each) => typeof each === 'string')) {
186
+ return undefined;
187
+ }
188
+ return { name, ...(materialized === true ? { materialized } : {}), create };
149
189
  }
package/src/sqlstate.ts CHANGED
@@ -27,6 +27,10 @@ export const SQLSTATE = Object.freeze({
27
27
  queryCanceled: '57014',
28
28
  /** `lock_not_available` — what `lock_timeout` raises while a DDL statement queues. */
29
29
  lockNotAvailable: '55P03',
30
+ /** `protocol_violation` — a pooler refusing a startup parameter says it in this class. */
31
+ protocolViolation: '08P01',
32
+ /** `idle_in_transaction_session_timeout` — the server closed a transaction nobody was using. */
33
+ idleInTransactionTimeout: '25P03',
30
34
  tooManyConnections: '53300',
31
35
  outOfMemory: '53200',
32
36
  } as const);
@@ -0,0 +1,82 @@
1
+ // Single responsibility: a time bound for ONE read, tighter than the role's. `statement_timeout`
2
+ // is per role (`libpq-options.ts`); a search that must give up at 500 ms and a report allowed 60 s
3
+ // share it unless the read states its own, transaction-local, through the one `setLocal` seam.
4
+
5
+ import type { DbClient } from './client';
6
+ import { DbError } from './errors';
7
+ import { setLocal } from './local-settings';
8
+ import { withReadTransaction } from './read-transaction';
9
+ import { sql } from './sql';
10
+ import { currentTx } from './transaction';
11
+ import type { DbTx } from './transaction-options';
12
+
13
+ const FIX =
14
+ 'query({ timeoutMs: 500 }) # a whole number of ms below the role bound, read with: psql "$DATABASE_URL" -c \'show statement_timeout\'';
15
+
16
+ function boundInvalid(ms: number, why: string): DbError {
17
+ return new DbError({
18
+ code: 'X_DB_STATEMENT_BOUND_INVALID',
19
+ cause: `withStatementTimeout(${String(ms)}) — ${why}`,
20
+ fix: FIX,
21
+ });
22
+ }
23
+
24
+ export interface StatementBoundOptions {
25
+ /** Override the ambient pool, as `withTransaction`'s `client` does. */
26
+ readonly client?: DbClient | undefined;
27
+ }
28
+
29
+ interface BoundRow {
30
+ readonly ms: string | number | null;
31
+ }
32
+
33
+ /**
34
+ * The bound in force on `tx` now, in ms; `0` is none. Read from `pg_settings`, whose `setting` is
35
+ * already milliseconds — `current_setting` answers `10s`, a unit this would have to parse.
36
+ */
37
+ async function boundOn(tx: DbTx): Promise<number> {
38
+ const row = await tx.one<BoundRow>(
39
+ sql`select setting as ms from pg_settings where name = 'statement_timeout'`,
40
+ );
41
+ return Number(row?.ms ?? 0);
42
+ }
43
+
44
+ /**
45
+ * `fn` inside a read-only transaction whose statements are bounded at `ms`. A transaction is pinned
46
+ * to the primary, so a bounded read never reaches a replica. Inside an open transaction the bound is
47
+ * set, `fn` runs, and the prior bound is put back — the rest of that transaction keeps its own.
48
+ *
49
+ * Refused above the bound already in force (`X_DB_STATEMENT_BOUND_INVALID`): a read cannot extend
50
+ * what the role was sized for. The bound firing is `57014`, which stays `X_DB_STATEMENT_TIMEOUT`.
51
+ */
52
+ export function withStatementTimeout<T>(
53
+ ms: number,
54
+ fn: (tx: DbTx) => Promise<T>,
55
+ options: StatementBoundOptions = {},
56
+ ): Promise<T> {
57
+ if (!Number.isSafeInteger(ms) || ms <= 0) {
58
+ return Promise.reject(boundInvalid(ms, 'a bound is a whole number of milliseconds above 0'));
59
+ }
60
+ const nested = currentTx() !== undefined;
61
+ return withReadTransaction(async (tx) => {
62
+ const prior = await boundOn(tx);
63
+ if (prior > 0 && ms > prior) {
64
+ throw boundInvalid(
65
+ ms,
66
+ `the bound in force is ${String(prior)} ms, and a read may only tighten it`,
67
+ );
68
+ }
69
+ await setLocal(tx, { statement_timeout: String(ms) });
70
+ if (!nested) return fn(tx);
71
+ let result: T;
72
+ try {
73
+ result = await fn(tx);
74
+ } catch (error) {
75
+ // Aborted by `fn`'s own failure, the restore would fail too; the abort discards the bound.
76
+ await setLocal(tx, { statement_timeout: String(prior) }).catch(() => undefined);
77
+ throw error;
78
+ }
79
+ await setLocal(tx, { statement_timeout: String(prior) });
80
+ return result;
81
+ }, options);
82
+ }