@ultimat3/db 29.4.0 → 30.0.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
package/src/retired.ts ADDED
@@ -0,0 +1,131 @@
1
+ // Single responsibility: what `entity({ retired: [...] })` means to `x db gen`. A retired column has
2
+ // left the entity, so nothing in the new release reads it, and stays in the table and the snapshot,
3
+ // so the old release keeps working. Retiring writes a marker and no statement; the drop comes in a
4
+ // later migration, by the ordinary arm, which only then finds the column it may take.
5
+
6
+ import { columnCheckNameOf } from './check-ddl';
7
+ import type { EntityDescriptionLike } from './entity-shape';
8
+ import { foreignKeysOf, type Plan } from './foreign-key-plan';
9
+ import type { ColumnDescription, SchemaDescription, TableDescription } from './introspect';
10
+ import { findTable } from './introspect';
11
+ import { contractsMarker, retiresMarker, unretiresMarker } from './rolling';
12
+ import {
13
+ retiredColumnReferenced,
14
+ retiredColumnRequired,
15
+ retiredNameUnknown,
16
+ } from './rolling-errors';
17
+ import { retiredKeyIndexes } from './same-tenant';
18
+ import { identifier } from './sql';
19
+
20
+ /** The names, as `retiresMarker` writes them: through `identifier()`, which refuses a quote. */
21
+ const bare = (name: string): string => identifier(name).text.slice(1, -1);
22
+
23
+ /** The line above a retired column's drop, which is what `retireAndContractPending` pairs up. */
24
+ export const contractsMarkerFor = (table: string, column: string): string =>
25
+ contractsMarker(bare(table), bare(column));
26
+
27
+ /**
28
+ * Refuse what cannot be retired, and mark what newly is. Marker-only by design: indexes, foreign
29
+ * keys and the column's own CHECK stay until the drop takes them with the column.
30
+ *
31
+ * Also the way back: a retired column declared again gets a marker of its own, because the only
32
+ * thing that moved is the snapshot and an empty `up` writes no file (`generateAppMigration`) — the
33
+ * flag would stay on the record, and a later removal would read as retired when it never was.
34
+ */
35
+ export function retirePlan(
36
+ entity: EntityDescriptionLike,
37
+ live: TableDescription,
38
+ plan: Plan,
39
+ entities: readonly EntityDescriptionLike[],
40
+ ): void {
41
+ const pointedAt = new Set(
42
+ entities
43
+ .flatMap(foreignKeysOf)
44
+ .filter((key) => key.referencedTable === entity.table)
45
+ .flatMap((key) => key.referencedColumns),
46
+ );
47
+ const recorded = new Map(live.columns.map((column) => [column.name, column]));
48
+ const declared = new Set(entity.columns.map((column) => column.column));
49
+ for (const name of entity.retired ?? []) {
50
+ const column = recorded.get(name);
51
+ if (column === undefined || declared.has(name)) {
52
+ throw retiredNameUnknown(entity.name, entity.table, name);
53
+ }
54
+ // Declared keys, not recorded ones: a key this same migration lets go is no obstacle.
55
+ if (pointedAt.has(name)) throw retiredColumnReferenced(entity.name, entity.table, name);
56
+ if (column.retired === true) continue;
57
+ const fillable = column.nullable || column.default !== null || column.generated !== undefined;
58
+ if (!fillable || live.primaryKey.includes(name)) {
59
+ throw retiredColumnRequired(entity.name, entity.table, name);
60
+ }
61
+ plan.up.push(retiresMarker(bare(entity.table), bare(name)));
62
+ }
63
+ for (const column of live.columns) {
64
+ if (column.retired === true && declared.has(column.name)) {
65
+ plan.up.push(unretiresMarker(bare(entity.table), bare(column.name)));
66
+ }
67
+ }
68
+ }
69
+
70
+ function recordInTable(
71
+ table: TableDescription,
72
+ live: TableDescription,
73
+ entity: EntityDescriptionLike,
74
+ ): TableDescription {
75
+ const names = new Set(entity.retired ?? []);
76
+ const over = (columns: readonly string[]): boolean => columns.some((each) => names.has(each));
77
+ const kept: ColumnDescription[] = live.columns
78
+ .filter((column) => names.has(column.name))
79
+ .map((column) => ({ ...column, retired: true }));
80
+ const columns = [...table.columns, ...kept]
81
+ .sort((a, b) => (a.name < b.name ? -1 : 1))
82
+ .map((column, index) => ({ ...column, position: index + 1 }));
83
+ const checkNames = new Set([...names].map((name) => columnCheckNameOf(entity.table, name)));
84
+ const checks = [
85
+ ...(table.checks ?? []),
86
+ ...(live.checks ?? []).filter((check) => checkNames.has(check.name)),
87
+ ];
88
+ return {
89
+ ...table,
90
+ columns,
91
+ indexes: [...table.indexes, ...live.indexes.filter((index) => over(index.columns))],
92
+ foreignKeys: [
93
+ ...table.foreignKeys,
94
+ ...live.foreignKeys.filter((key) => over(key.columns)),
95
+ ].sort((a, b) => (a.name < b.name ? -1 : 1)),
96
+ ...(checks.length === 0 ? {} : { checks }),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * `snapshotOf` carried one step on: what declarations alone cannot say, read off the recorded
102
+ * schema. One function for both readers, `x db gen` and the `drift` step, because the second asks
103
+ * whether the first would write anything: two answers would be drift on a correct app.
104
+ */
105
+ export function recordRetired(
106
+ snapshot: SchemaDescription,
107
+ entities: readonly EntityDescriptionLike[],
108
+ current: SchemaDescription,
109
+ ): SchemaDescription {
110
+ const retiring = new Map(
111
+ entities
112
+ .filter((entity) => (entity.retired ?? []).length > 0)
113
+ .map((entity) => [entity.table, entity]),
114
+ );
115
+ if (retiring.size === 0) return snapshot;
116
+ const keptIndexes = retiredKeyIndexes(entities, current);
117
+ return {
118
+ ...snapshot,
119
+ tables: snapshot.tables.map((recorded) => {
120
+ const known = new Set(recorded.indexes.map((index) => index.name));
121
+ const kept = (keptIndexes.get(recorded.name) ?? []).filter((index) => !known.has(index.name));
122
+ const table =
123
+ kept.length === 0 ? recorded : { ...recorded, indexes: [...recorded.indexes, ...kept] };
124
+ const entity = retiring.get(table.name);
125
+ const live = findTable(current, table.name);
126
+ return entity === undefined || live === undefined
127
+ ? table
128
+ : recordInTable(table, live, entity);
129
+ }),
130
+ };
131
+ }
@@ -102,6 +102,8 @@ export function retypeDependents(column: string, live: TableDescription): Retype
102
102
  export interface MovedAside {
103
103
  readonly indexes: Set<string>;
104
104
  readonly checks: Set<string>;
105
+ /** Columns whose default the retype already moved, so the in-place arm has nothing to do. */
106
+ readonly defaults: Set<string>;
105
107
  }
106
108
 
107
109
  /**
@@ -0,0 +1,97 @@
1
+ // Single responsibility: every way `X_MIGRATION_ROLLING_UNSAFE` is raised, and the path each one
2
+ // prints. One code, because all of them are one fact: the previous release is still running. Apart
3
+ // from `migration-errors.ts` for its size, and because this one reads `rolling.ts`'s vocabulary.
4
+
5
+ import { renderFixShellArg } from '@ultimat3/core';
6
+ import { DbError } from './errors';
7
+ import type { RetireAndContract, RollingUnsafeStatement } from './rolling';
8
+
9
+ const CODE = 'X_MIGRATION_ROLLING_UNSAFE';
10
+
11
+ /**
12
+ * The path, whole, once. Followed verbatim it ends with the column gone and neither release ever
13
+ * having read a column that was not there (`retired.live.test.ts`). Every name is from a sidecar,
14
+ * an entity or a migration's text, so it reaches the command through the shell screen.
15
+ */
16
+ function twoReleases(entity: string, column: string): string {
17
+ const c = renderFixShellArg(column, '<column>');
18
+ return (
19
+ `entity('${renderFixShellArg(entity, '<entity>')}', { retired: ['${c}'] }) # then x db gen "retire ${c}", deploy, ` +
20
+ `then remove it from retired and x db gen "drop ${c}" --allow-destructive`
21
+ );
22
+ }
23
+
24
+ /** `x db gen`: a column left the entity with no `retired` in between. */
25
+ export const columnDropNotRetired = (
26
+ entity: string,
27
+ table: string,
28
+ column: string,
29
+ migration: string,
30
+ ): DbError =>
31
+ new DbError({
32
+ code: CODE,
33
+ cause:
34
+ `dropping "${table}"."${column}" in the release that stops declaring it fails every read and ` +
35
+ 'insert of the pods still running the previous release, which name it (X_DB_SCHEMA_STALE)',
36
+ fix: `${twoReleases(entity, column)} — one instance that stops before the next starts: x db gen ${renderFixShellArg(migration, '<name>')} --allow-destructive --rolling-unsafe`,
37
+ meta: { table, column },
38
+ });
39
+
40
+ /** `retired` names what no migration recorded, or a column the entity still declares. */
41
+ export const retiredNameUnknown = (entity: string, table: string, column: string): DbError =>
42
+ new DbError({
43
+ code: CODE,
44
+ cause: `entity "${entity}" retires "${column}", and "${table}" records no such column to retire, or still declares it`,
45
+ fix: `entity('${renderFixShellArg(entity, '<entity>')}', { retired: [] }) # drop "${renderFixShellArg(column, '<column>')}" from retired: list only a column the newest migration records and columns no longer declares`,
46
+ meta: { table, column },
47
+ });
48
+
49
+ /** The new release omits it from every insert, so the database must be able to fill it. */
50
+ export const retiredColumnRequired = (entity: string, table: string, column: string): DbError =>
51
+ new DbError({
52
+ code: CODE,
53
+ cause:
54
+ `"${table}"."${column}" is NOT NULL with no default or part of the primary key, so an ` +
55
+ 'insert from the release that retires it would fail: that release no longer writes it',
56
+ fix: `${renderFixShellArg(column, '<column>')}: text().nullable() # on entity('${renderFixShellArg(entity, '<entity>')}'), then x db gen "relax ${renderFixShellArg(column, '<column>')}", deploy, then retire it`,
57
+ meta: { table, column },
58
+ });
59
+
60
+ /** The drop this retire promises would be `2BP01` at apply, so it is refused while it is cheap. */
61
+ export const retiredColumnReferenced = (entity: string, table: string, column: string): DbError =>
62
+ new DbError({
63
+ code: CODE,
64
+ cause: `"${table}"."${column}" is the target of a foreign key an entity still declares, so the drop that follows this retire could never apply (2BP01)`,
65
+ fix: `references(() => target.id) # repoint or remove every references() to "${renderFixShellArg(column, '<column>')}" of entity('${renderFixShellArg(entity, '<entity>')}'), then x db gen`,
66
+ meta: { table, column },
67
+ });
68
+
69
+ const STATED: Readonly<Record<RollingUnsafeStatement['kind'], string>> = {
70
+ 'drop-column': 'drops a column no earlier migration retired',
71
+ 'rename-column': 'renames a column the previous release still names',
72
+ 'rename-table': 'renames a table the previous release still names',
73
+ };
74
+
75
+ /** The gate's half, over a committed file. One per migration, as `migrationDestructive` is. */
76
+ export const migrationRollingUnsafe = (
77
+ file: string,
78
+ first: RollingUnsafeStatement,
79
+ more = 0,
80
+ ): DbError =>
81
+ new DbError({
82
+ code: CODE,
83
+ cause: `${file} ${STATED[first.kind]}${more === 0 ? '' : ` (and ${more} more)`}: ${first.statement}`,
84
+ fix: `${twoReleases(first.table, first.column ?? '<column>')} — one instance that stops before the next starts: add the line "-- rolling-unsafe: true" to ${file} before it is applied`,
85
+ meta: { file, kind: first.kind, table: first.table, column: first.column },
86
+ });
87
+
88
+ /** `migrate()`, before the first BEGIN: both are fine, both in one deploy is not. */
89
+ export const retireAndContractTogether = (pair: RetireAndContract): DbError =>
90
+ new DbError({
91
+ code: CODE,
92
+ cause:
93
+ `migrations "${pair.retire}" and "${pair.contract}" retire and drop "${pair.table}"."${pair.column}" in ` +
94
+ 'one deploy, so the pods of the release before them meet the drop; nothing was applied',
95
+ fix: `git log --oneline -- packages/db/migrations # deploy the build whose newest migration is the retire, then this one`,
96
+ meta: { retire: pair.retire, contract: pair.contract },
97
+ });
package/src/rolling.ts ADDED
@@ -0,0 +1,212 @@
1
+ // Single responsibility: decide whether a migration's `up` would break the pods still running the
2
+ // old release, and read the markers that say a drop took two releases. `destructive.ts`'s shape,
3
+ // and the same path: `statementsOf`, then lexical. A rolling deploy runs N and N-1 at once, and N-1
4
+ // names every column it declares in every statement, so a drop before it is gone is an outage.
5
+
6
+ import { REBUILDABLE_DROP_CLAUSES } from './destructive';
7
+ import { markerLines } from './marker-line';
8
+ import { noiseAt } from './sql-scan';
9
+ import { statementExcerpt } from './statement-excerpt';
10
+ import { statementsOf } from './statement-split';
11
+
12
+ /** The line a migration carries when one instance stops before the next starts. */
13
+ export const ROLLING_UNSAFE_MARKER = '-- rolling-unsafe: true';
14
+
15
+ const ROLLING_UNSAFE_COMMENT = /^--[ \t]*rolling-unsafe:[ \t]*true[ \t]*\r?\n?$/i;
16
+
17
+ export const hasRollingUnsafeMarker = (sql: string): boolean =>
18
+ markerLines(sql, ROLLING_UNSAFE_COMMENT).length > 0;
19
+
20
+ /**
21
+ * Spelled through `identifier()` by every writer (`retired.ts`), so the comment holds no newline and
22
+ * no second quote for a reader to trip on, and this one reads back exactly what was written.
23
+ */
24
+ const quotedPair = (table: string, column: string): string => `"${table}"."${column}"`;
25
+ export const retiresMarker = (table: string, column: string): string =>
26
+ `-- retires ${quotedPair(table, column)}`;
27
+ export const contractsMarker = (table: string, column: string): string =>
28
+ `-- contracts ${quotedPair(table, column)}`;
29
+ export const unretiresMarker = (table: string, column: string): string =>
30
+ `-- unretires ${quotedPair(table, column)}`;
31
+
32
+ const namedBy = (verb: string): RegExp =>
33
+ new RegExp(`^--[ \\t]*${verb}[ \\t]+"([^"]+)"\\."([^"]+)"[ \\t]*\\r?\\n?$`, 'i');
34
+
35
+ const RETIRES = namedBy('retires');
36
+ const CONTRACTS = namedBy('contracts');
37
+ const UNRETIRES = namedBy('unretires');
38
+
39
+ /** One column as one string, for a set: compared, never parsed, and no two pairs share one. */
40
+ export const columnKey = (table: string, column: string): string => JSON.stringify([table, column]);
41
+
42
+ interface NamedColumn {
43
+ readonly table: string;
44
+ readonly column: string;
45
+ }
46
+
47
+ function columnsNamedBy(sql: string, marker: RegExp): readonly NamedColumn[] {
48
+ return markerLines(sql, marker).flatMap((line) => {
49
+ const match = marker.exec(line);
50
+ const [table, column] = [match?.[1], match?.[2]];
51
+ return table === undefined || column === undefined ? [] : [{ table, column }];
52
+ });
53
+ }
54
+
55
+ /** The columns this `up` declares retired, as `columnKey`. */
56
+ export const retiredColumnsIn = (up: string): readonly string[] =>
57
+ columnsNamedBy(up, RETIRES).map(({ table, column }) => columnKey(table, column));
58
+
59
+ /** The columns this `up` takes back: a retire before it earns no later drop. */
60
+ export const unretiredColumnsIn = (up: string): readonly string[] =>
61
+ columnsNamedBy(up, UNRETIRES).map(({ table, column }) => columnKey(table, column));
62
+
63
+ /** The closed list. A rail that tried every Postgres foot-gun would be a second SQL parser. */
64
+ export type RollingUnsafeKind = 'drop-column' | 'rename-column' | 'rename-table';
65
+
66
+ export interface RollingUnsafeStatement {
67
+ readonly kind: RollingUnsafeKind;
68
+ readonly table: string;
69
+ /** The column, or the table's old name for `rename-table`, which has no column. */
70
+ readonly column: string | null;
71
+ /** The statement as written, on one line. */
72
+ readonly statement: string;
73
+ }
74
+
75
+ /**
76
+ * Words, quoted identifiers and punctuation, with comments, literals and dollar bodies gone. Why
77
+ * not `stripSqlNoise`, which `destructive.ts` reads: it blanks a quoted identifier too, and this
78
+ * rail has to say WHICH column.
79
+ */
80
+ function tokensOf(statement: string): readonly string[] {
81
+ let text = '';
82
+ let index = 0;
83
+ while (index < statement.length) {
84
+ const noise = noiseAt(statement, index);
85
+ if (noise === null) {
86
+ text += statement[index];
87
+ index += 1;
88
+ continue;
89
+ }
90
+ if (noise.kind === 'identifier') text += statement.slice(index, noise.end);
91
+ else text += noise.kind.endsWith('comment') ? ' ' : " '' ";
92
+ index = noise.end;
93
+ }
94
+ return text.match(/"(?:[^"]|"")*"|[A-Za-z_][\w$]*|\S/g) ?? [];
95
+ }
96
+
97
+ const isWord = (lexeme: string | undefined, word: string): boolean =>
98
+ lexeme !== undefined && !lexeme.startsWith('"') && lexeme.toLowerCase() === word;
99
+
100
+ /** What Postgres folds a token to: a quoted one keeps its case, a bare one lowers. */
101
+ const nameOf = (token: string): string =>
102
+ token.startsWith('"') ? token.slice(1, -1).replaceAll('""', '"') : token.toLowerCase();
103
+
104
+ /** `alter table [if exists] [only] [schema.]t`, as the table and where its subcommands start. */
105
+ function alteredTable(tokens: readonly string[]): { table: string; next: number } | undefined {
106
+ if (!isWord(tokens[0], 'alter') || !isWord(tokens[1], 'table')) return undefined;
107
+ let at = 2;
108
+ if (isWord(tokens[at], 'if') && isWord(tokens[at + 1], 'exists')) at += 2;
109
+ if (isWord(tokens[at], 'only')) at += 1;
110
+ let name = tokens[at];
111
+ at += 1;
112
+ while (tokens[at] === '.') {
113
+ name = tokens[at + 1];
114
+ at += 2;
115
+ }
116
+ return name === undefined ? undefined : { table: nameOf(name), next: at };
117
+ }
118
+
119
+ /** Top-level commas only: `add constraint … (a, b)` is one subcommand. */
120
+ function subcommands(tokens: readonly string[]): readonly (readonly string[])[] {
121
+ const parts: string[][] = [[]];
122
+ let depth = 0;
123
+ for (const token of tokens) {
124
+ if (token === '(') depth += 1;
125
+ if (token === ')') depth -= 1;
126
+ if (token === ',' && depth === 0) parts.push([]);
127
+ else parts.at(-1)?.push(token);
128
+ }
129
+ return parts;
130
+ }
131
+
132
+ function judged(
133
+ table: string,
134
+ part: readonly string[],
135
+ ): { readonly kind: RollingUnsafeKind; readonly column: string | null } | undefined {
136
+ const [verb, second] = part;
137
+ if (isWord(verb, 'drop')) {
138
+ if (second === undefined || REBUILDABLE_DROP_CLAUSES.includes(second.toLowerCase()))
139
+ return undefined;
140
+ let at = isWord(second, 'column') ? 2 : 1;
141
+ if (isWord(part[at], 'if') && isWord(part[at + 1], 'exists')) at += 2;
142
+ const name = part[at];
143
+ return name === undefined ? undefined : { kind: 'drop-column', column: nameOf(name) };
144
+ }
145
+ if (!isWord(verb, 'rename') || second === undefined) return undefined;
146
+ if (isWord(second, 'to')) return { kind: 'rename-table', column: table };
147
+ if (isWord(second, 'constraint')) return undefined;
148
+ const name = isWord(second, 'column') ? part[2] : second;
149
+ return name === undefined ? undefined : { kind: 'rename-column', column: nameOf(name) };
150
+ }
151
+
152
+ /**
153
+ * Every statement in `up` a pod of the previous release would still trip over, in apply order.
154
+ *
155
+ * `retired` holds the columns an EARLIER migration marked, as `columnKey`: a drop of one of those
156
+ * is what the two-release path is for. A retire in the same file is not earlier, since one deploy
157
+ * would apply both. Only `up`, the rule `destructive.ts` states.
158
+ */
159
+ export function rollingUnsafeStatements(
160
+ up: string,
161
+ retired: ReadonlySet<string> = new Set(),
162
+ ): readonly RollingUnsafeStatement[] {
163
+ const found: RollingUnsafeStatement[] = [];
164
+ for (const statement of statementsOf(up)) {
165
+ const tokens = tokensOf(statement);
166
+ const altered = alteredTable(tokens);
167
+ if (altered === undefined) continue;
168
+ for (const part of subcommands(tokens.slice(altered.next))) {
169
+ const verdict = judged(altered.table, part);
170
+ if (verdict === undefined) continue;
171
+ if (
172
+ verdict.kind === 'drop-column' &&
173
+ verdict.column !== null &&
174
+ retired.has(columnKey(altered.table, verdict.column))
175
+ ) {
176
+ continue;
177
+ }
178
+ found.push({
179
+ kind: verdict.kind,
180
+ table: altered.table,
181
+ column: verdict.column,
182
+ statement: statementExcerpt(statement),
183
+ });
184
+ }
185
+ }
186
+ return found;
187
+ }
188
+
189
+ export interface RetireAndContract {
190
+ readonly table: string;
191
+ readonly column: string;
192
+ readonly retire: string;
193
+ readonly contract: string;
194
+ }
195
+
196
+ /**
197
+ * The pair the static rail cannot see: both written properly in separate migrations, and both about
198
+ * to land in one deploy, where old pods meet the drop anyway. The first such pair, or `undefined`.
199
+ */
200
+ export function retireAndContractPending(
201
+ pending: readonly { readonly id: string; readonly up: string }[],
202
+ ): RetireAndContract | undefined {
203
+ const retires = new Map<string, string>();
204
+ for (const migration of pending) {
205
+ for (const { table, column } of columnsNamedBy(migration.up, CONTRACTS)) {
206
+ const retire = retires.get(columnKey(table, column));
207
+ if (retire !== undefined) return { table, column, retire, contract: migration.id };
208
+ }
209
+ for (const key of retiredColumnsIn(migration.up)) retires.set(key, migration.id);
210
+ }
211
+ return undefined;
212
+ }
@@ -0,0 +1,54 @@
1
+ // Single responsibility: with row security on, no statement reaches the database outside a
2
+ // transaction that carries the tenant. A statement sent bare — a raw `sql`, a report, a repository
3
+ // read — is wrapped in one: BEGIN, the tenant's `set_config`, the statement, COMMIT.
4
+
5
+ import type { DbClient } from './client';
6
+ import { isPlainRead } from './replica-route';
7
+ import type { SqlFragment } from './sql';
8
+ import { statementVerb } from './statement-shape';
9
+ import { currentTx, withTransaction } from './transaction';
10
+ import { transactionSettings } from './transaction-settings';
11
+
12
+ const SENDS: ReadonlySet<PropertyKey> = new Set(['query', 'one', 'execute']);
13
+
14
+ /**
15
+ * A transaction's own control statements, sent by `withTransaction` on a client it could not pin
16
+ * (a non-reservable one sends BEGIN through this very proxy): wrapping those would nest a second
17
+ * transaction around the first one's BEGIN.
18
+ */
19
+ const CONTROL: ReadonlySet<string> = new Set([
20
+ 'begin',
21
+ 'commit',
22
+ 'rollback',
23
+ 'savepoint',
24
+ 'release',
25
+ ]);
26
+
27
+ /**
28
+ * `inner`, every other member untouched (`close`, `listen`, `profile`, `reserve`), with its three
29
+ * sends wrapped. The accepted cost — a transaction per bare statement — is paid only when the
30
+ * provider has a tenant to set; with none the statement goes as it came and the policy, reading an
31
+ * unset tenant, answers no rows. A statement already inside a transaction is that transaction's.
32
+ */
33
+ export function rowSecureClient<C extends DbClient>(inner: C): C {
34
+ const wrapped = <T>(text: string, send: (client: DbClient) => Promise<T>): Promise<T> => {
35
+ if (currentTx() !== undefined || transactionSettings() === undefined) return send(inner);
36
+ if (CONTROL.has(statementVerb(text))) return send(inner);
37
+ // `client: inner`, never this proxy: the BEGIN must pin a connection of the real pool. A plain
38
+ // read is `READ ONLY`, so it does not mark a `withReplicaReads` scope as having written.
39
+ return withTransaction((tx) => send(tx), { client: inner, readOnly: isPlainRead(text) });
40
+ };
41
+ const sends: DbClient = {
42
+ query: <T>(fragment: SqlFragment) =>
43
+ wrapped(fragment.text, (client) => client.query<T>(fragment)),
44
+ one: <T>(fragment: SqlFragment) => wrapped(fragment.text, (client) => client.one<T>(fragment)),
45
+ execute: (fragment: SqlFragment) =>
46
+ wrapped(fragment.text, (client) => client.execute(fragment)),
47
+ };
48
+ return new Proxy(inner, {
49
+ get(target, property, receiver) {
50
+ if (SENDS.has(property)) return Reflect.get(sends, property);
51
+ return Reflect.get(target, property, receiver);
52
+ },
53
+ });
54
+ }
@@ -0,0 +1,26 @@
1
+ // Single responsibility: the catalog's half of a table's row security, as names — the policies on
2
+ // each table whose row security is enabled AND forced. A policy on a table that is not forced binds
3
+ // no owner, and one on a table that is not enabled binds nobody, so neither is held.
4
+
5
+ import type { DbClient } from './client';
6
+ import { sql } from './sql';
7
+
8
+ /** One policy on a table whose row security binds: what `introspectSchema()` reads. */
9
+ export interface PolicyNameRow {
10
+ readonly table_name: string;
11
+ readonly policy_name: string;
12
+ }
13
+
14
+ /** Names only, as `triggerNames`: a guard is judged by being THERE, never by `pg_get_expr` text. */
15
+ export const policyNameRows = (
16
+ client: DbClient,
17
+ schema: string,
18
+ ): Promise<readonly PolicyNameRow[]> =>
19
+ client.query<PolicyNameRow>(sql`
20
+ select c.relname as table_name, p.polname as policy_name
21
+ from pg_policy p
22
+ join pg_class c on c.oid = p.polrelid
23
+ join pg_namespace n on n.oid = c.relnamespace
24
+ where n.nspname = ${schema} and c.relrowsecurity and c.relforcerowsecurity
25
+ order by c.relname, p.polname
26
+ `);
@@ -0,0 +1,48 @@
1
+ // Single responsibility: an embedded database's migrate step, held as `x_migrator`. On PGlite the
2
+ // one session is a superuser, so without this every table belongs to it and the default privileges
3
+ // `x_migrator` sets for `x_app` never apply. What the superuser already owns in `public` is handed
4
+ // over first — a table created before the flag must still be alterable by the next migration.
5
+
6
+ import type { DbClient } from './client';
7
+ import { ensureRowSecurityRoles, MIGRATOR_ROLE } from './row-security-roles';
8
+ import { identifier, raw } from './sql';
9
+
10
+ /**
11
+ * Every relation, type and routine in `public` the session's user owns, given to the migrator. A
12
+ * sequence a column owns follows its table (`alter sequence` on it is refused); an extension's
13
+ * member objects stay the extension's.
14
+ */
15
+ export function migratorOwnershipSql(): string {
16
+ const migrator = identifier(MIGRATOR_ROLE).text;
17
+ const member = (catalog: string): string =>
18
+ `not exists (select from pg_depend d where d.classid = '${catalog}'::regclass and d.objid = o.oid and d.deptype in ('a', 'i', 'e'))`;
19
+ return (
20
+ 'do $own$ declare r record; begin ' +
21
+ 'for r in select o.oid::regclass as name, ' +
22
+ "case o.relkind when 'v' then 'view' when 'm' then 'materialized view' when 'S' then 'sequence' when 'f' then 'foreign table' else 'table' end as kind " +
23
+ "from pg_class o where o.relnamespace = 'public'::regnamespace and o.relkind in ('r', 'p', 'v', 'm', 'S', 'f') " +
24
+ `and o.relowner = (select oid from pg_roles where rolname = current_user) and ${member('pg_class')} loop ` +
25
+ `execute format('alter %s %s owner to ${migrator}', r.kind, r.name); end loop; ` +
26
+ "for r in select o.oid::regtype as name from pg_type o where o.typnamespace = 'public'::regnamespace and o.typtype in ('e', 'd', 'r', 'm') " +
27
+ `and o.typowner = (select oid from pg_roles where rolname = current_user) and ${member('pg_type')} loop ` +
28
+ `execute format('alter type %s owner to ${migrator}', r.name); end loop; ` +
29
+ "for r in select o.oid::regprocedure as name from pg_proc o where o.pronamespace = 'public'::regnamespace " +
30
+ `and o.proowner = (select oid from pg_roles where rolname = current_user) and ${member('pg_proc')} loop ` +
31
+ `execute format('alter routine %s owner to ${migrator}', r.name); end loop; ` +
32
+ 'end $own$'
33
+ );
34
+ }
35
+
36
+ /**
37
+ * The roles, the hand-over, then `set role x_migrator` for the migrate step's scope. Returns the
38
+ * undo: `reset role`, so the session is the bootstrap user again for whatever runs next. Only on an
39
+ * engine whose roles are ours: an external database's migrator is the operator's `DATABASE_URL`.
40
+ */
41
+ export async function holdMigratorRole(client: DbClient): Promise<() => Promise<void>> {
42
+ await ensureRowSecurityRoles(client);
43
+ await client.execute(raw(migratorOwnershipSql()));
44
+ await client.execute(raw(`set role ${identifier(MIGRATOR_ROLE).text}`));
45
+ return async () => {
46
+ await client.execute(raw('reset role'));
47
+ };
48
+ }
@@ -0,0 +1,58 @@
1
+ // Single responsibility: what `x db gen` emits when `database.rowSecurity` flips — the policy on
2
+ // every tenant table, both directions, on the precedent of `generate-append-only.ts`. Pure over
3
+ // its inputs: which tables declare a tenant, and which already carry a recorded policy.
4
+
5
+ import type { Plan } from './foreign-key-plan';
6
+ import { dropRowSecuritySql, enableRowSecuritySql } from './row-security';
7
+ import { identifier } from './sql';
8
+
9
+ /** A table declaring `tenant:`, its tenant column's physical name, and that column's SQL type. */
10
+ export interface TenantTable {
11
+ readonly table: string;
12
+ readonly column: string;
13
+ readonly type: string;
14
+ }
15
+
16
+ export interface RowSecurityPlanInput {
17
+ /** `database.rowSecurity`. Off removes every recorded policy; on adds every missing one. */
18
+ readonly enabled: boolean;
19
+ readonly tenantTables: readonly TenantTable[];
20
+ /** Tables the latest snapshot records as carrying the policy. */
21
+ readonly recorded: ReadonlySet<string>;
22
+ /** The migrator role the app role's grants are issued from, or none (grants left to the operator). */
23
+ readonly appRole?: string | undefined;
24
+ }
25
+
26
+ /**
27
+ * Up: enable, force, policy, and the app role's grants on each table that should carry one and is
28
+ * not recorded; drop each recorded one that should not. Down reverses each. A table created in the
29
+ * same migration needs no `down` of its own — its `down` is `drop table`, and this runs after it.
30
+ * `x_*` tables declare no tenant and so are never here.
31
+ */
32
+ export function rowSecurityPlan(plan: Plan, input: RowSecurityPlanInput): void {
33
+ const wanted = new Set(input.enabled ? input.tenantTables.map((entry) => entry.table) : []);
34
+ for (const entry of input.tenantTables) {
35
+ if (!wanted.has(entry.table) || input.recorded.has(entry.table)) continue;
36
+ plan.up.push(...enableRowSecuritySql(entry.table, entry.column, entry.type));
37
+ if (input.appRole !== undefined) {
38
+ plan.up.push(
39
+ `grant select, insert, update, delete on ${identifier(entry.table).text} to ${identifier(input.appRole).text};`,
40
+ );
41
+ }
42
+ // `down` is reversed whole, so these run drop-policy first.
43
+ plan.down.push(...[...dropRowSecuritySql(entry.table)].reverse());
44
+ }
45
+ for (const table of [...input.recorded].sort()) {
46
+ if (wanted.has(table)) continue;
47
+ const entry = input.tenantTables.find((candidate) => candidate.table === table);
48
+ plan.up.push(...dropRowSecuritySql(table));
49
+ if (entry !== undefined) {
50
+ plan.down.push(...[...enableRowSecuritySql(entry.table, entry.column, entry.type)].reverse());
51
+ }
52
+ }
53
+ }
54
+
55
+ /** What the snapshot records after this migration: the tables that carry the policy. */
56
+ export function rowSecurityAfter(input: RowSecurityPlanInput): ReadonlySet<string> {
57
+ return new Set(input.enabled ? input.tenantTables.map((entry) => entry.table) : []);
58
+ }