@rehearsal-db/core 0.1.0-beta.7 → 0.1.0-beta.9

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 (64) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/COMPATIBILITY.md +15 -3
  3. package/README.md +31 -13
  4. package/docs/README.md +29 -0
  5. package/docs/adapters.md +15 -6
  6. package/docs/architecture.md +58 -0
  7. package/docs/baselines.md +22 -1
  8. package/docs/commands.md +81 -14
  9. package/docs/configuration.md +144 -11
  10. package/docs/getting-started.md +12 -2
  11. package/docs/glossary.md +4 -0
  12. package/docs/production-source.md +176 -33
  13. package/docs/roadmap.md +30 -28
  14. package/docs/runtime-policies.md +191 -0
  15. package/docs/sanitization.md +24 -8
  16. package/docs/security-model.md +29 -8
  17. package/docs/standalone-workflow.md +107 -0
  18. package/docs/troubleshooting.md +8 -0
  19. package/package.json +25 -22
  20. package/scripts/runtime/manage_database.mjs +18 -0
  21. package/src/README.md +17 -0
  22. package/src/application/session.mjs +313 -0
  23. package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
  24. package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
  25. package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
  26. package/src/baseline/privacy_engine.mjs +413 -0
  27. package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
  28. package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
  29. package/src/cli/arguments.mjs +120 -0
  30. package/src/cli/guided.mjs +807 -0
  31. package/src/cli/rehearsal.mjs +933 -0
  32. package/src/cli/renderers.mjs +584 -0
  33. package/src/cli/runtime_commands.mjs +553 -0
  34. package/src/cli/source_commands.mjs +326 -0
  35. package/src/cli/terminal.mjs +275 -0
  36. package/src/identity/claim.mjs +975 -0
  37. package/src/identity/storage.mjs +165 -0
  38. package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +34 -0
  39. package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
  40. package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
  41. package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
  42. package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
  43. package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
  44. package/src/runtime/policy.mjs +438 -0
  45. package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
  46. package/src/runtime/topology.mjs +177 -0
  47. package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
  48. package/src/shared/operation_guard.mjs +162 -0
  49. package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
  50. package/src/source/access.mjs +578 -0
  51. package/src/source/asset_transfer.mjs +177 -0
  52. package/src/source/baseline.mjs +446 -0
  53. package/src/source/postgresql_access.mjs +480 -0
  54. package/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
  55. package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
  56. package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
  57. package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
  58. package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
  59. package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
  60. /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
  61. /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
  62. /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
  63. /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
  64. /package/{scripts/lib/rehearsal → src/shared}/human_output.mjs +0 -0
@@ -9,9 +9,9 @@ import { stat } from "node:fs/promises";
9
9
  import {
10
10
  planBaselineGenerationPrune,
11
11
  pruneBaselineGenerations,
12
- } from "./baseline_artifact.mjs";
13
- import { loadRehearsalConfig } from "./configuration.mjs";
14
- import { createCleanProcessEnvironment } from "./process_environment.mjs";
12
+ } from "../baseline/artifact.mjs";
13
+ import { loadRehearsalConfig } from "../project/configuration.mjs";
14
+ import { createCleanProcessEnvironment } from "../shared/process_environment.mjs";
15
15
 
16
16
  const SUPABASE_IMAGE_PREFIX = "public.ecr.aws/supabase/";
17
17
 
@@ -10,19 +10,19 @@ import { createConnection } from "node:net";
10
10
  import { join } from "node:path";
11
11
  import { pathToFileURL } from "node:url";
12
12
  import { performance } from "node:perf_hooks";
13
- import { verifyActiveBaseline } from "./baseline_artifact.mjs";
13
+ import { verifyActiveBaseline } from "../baseline/artifact.mjs";
14
14
  import {
15
15
  inspectDetectedProject,
16
16
  loadRehearsalConfig,
17
- } from "./configuration.mjs";
18
- import { createCandidateMigrationReceipt } from "./runtime_restore.mjs";
17
+ } from "../project/configuration.mjs";
18
+ import { createCandidateMigrationReceipt } from "./restore.mjs";
19
19
  import { readMigrationFileInventory } from "./migration_history.mjs";
20
- import { REHEARSAL_VERSION } from "./diagnostics.mjs";
20
+ import { REHEARSAL_VERSION } from "../shared/diagnostics.mjs";
21
21
  import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
22
22
  import {
23
23
  readBoundRuntimeSanitizationPolicy,
24
24
  validateRuntimeSanitizationPolicy,
25
- } from "./sanitization_policy.mjs";
25
+ } from "../baseline/sanitization_policy.mjs";
26
26
 
27
27
  const commandAvailable = (command, args = ["--version"]) => {
28
28
  const result = spawnSync(command, args, {
@@ -0,0 +1,438 @@
1
+ /** Build narrowly validated local restore prerequisites and structural checks. */
2
+
3
+ const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
4
+ const IDENTITY_NAME = /^[a-z0-9][a-z0-9-]{1,62}$/u;
5
+ const UUID =
6
+ /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
7
+ const EXTENSIONS = new Set([
8
+ "citext",
9
+ "pg_trgm",
10
+ "pgcrypto",
11
+ "unaccent",
12
+ "uuid-ossp",
13
+ "vector",
14
+ ]);
15
+
16
+ const object = (value, label) => {
17
+ if (
18
+ value === null ||
19
+ typeof value !== "object" ||
20
+ Array.isArray(value) ||
21
+ Object.getPrototypeOf(value) !== Object.prototype
22
+ ) {
23
+ throw new Error(`${label} must be an object.`);
24
+ }
25
+ return value;
26
+ };
27
+ const keys = (value, allowed, label) => {
28
+ for (const key of Object.keys(value))
29
+ if (!allowed.includes(key)) throw new Error(`${label}.${key} is unknown.`);
30
+ };
31
+ const id = (value, label) => {
32
+ if (!IDENTIFIER.test(value ?? ""))
33
+ throw new Error(`${label} is not a safe PostgreSQL identifier.`);
34
+ return value;
35
+ };
36
+ const qid = (value) => `"${id(value, "SQL identifier")}"`;
37
+ const qext = (value) => {
38
+ if (!EXTENSIONS.has(value))
39
+ throw new Error("Unsupported extension identifier.");
40
+ return `"${value}"`;
41
+ };
42
+ const literal = (value) => `'${String(value).replaceAll("'", "''")}'`;
43
+ const relation = (value, label) => {
44
+ object(value, label);
45
+ keys(value, ["schema", "name"], label);
46
+ return Object.freeze({
47
+ schema: id(value.schema, `${label}.schema`),
48
+ name: id(value.name, `${label}.name`),
49
+ });
50
+ };
51
+
52
+ export const IDENTITY_CLAIM_RECEIPT_SQL = `create schema if not exists rehearsal_internal;
53
+ create table if not exists rehearsal_internal.identity_claims (
54
+ identity_name text primary key,
55
+ placeholder_user_id uuid not null unique,
56
+ local_user_id uuid not null,
57
+ claimed_at timestamptz not null default now()
58
+ );`;
59
+
60
+ export const validateRuntimePolicy = (input) => {
61
+ const policy = object(input, "runtimePolicy");
62
+ keys(
63
+ policy,
64
+ ["policyVersion", "prerequisites", "triggers", "localRows", "expectations"],
65
+ "runtimePolicy",
66
+ );
67
+ if (policy.policyVersion !== 1)
68
+ throw new Error("Runtime policy must use policyVersion 1.");
69
+ const prerequisites = object(
70
+ policy.prerequisites ?? {},
71
+ "runtimePolicy.prerequisites",
72
+ );
73
+ keys(prerequisites, ["schemas", "extensions"], "runtimePolicy.prerequisites");
74
+ const schemas = (prerequisites.schemas ?? []).map((value, index) =>
75
+ id(value, `runtimePolicy.prerequisites.schemas[${index}]`),
76
+ );
77
+ const extensions = (prerequisites.extensions ?? []).map((entry, index) => {
78
+ const label = `runtimePolicy.prerequisites.extensions[${index}]`;
79
+ object(entry, label);
80
+ keys(entry, ["name", "schema"], label);
81
+ if (!EXTENSIONS.has(entry.name))
82
+ throw new Error(
83
+ `${label}.name is not in Rehearsal's reviewed extension allowlist.`,
84
+ );
85
+ return Object.freeze({
86
+ name: entry.name,
87
+ schema: id(entry.schema, `${label}.schema`),
88
+ });
89
+ });
90
+ const triggers = (policy.triggers ?? []).map((entry, index) => {
91
+ const label = `runtimePolicy.triggers[${index}]`;
92
+ object(entry, label);
93
+ keys(entry, ["name", "table", "timing", "events", "function"], label);
94
+ if (!["before", "after"].includes(entry.timing))
95
+ throw new Error(`${label}.timing is unsupported.`);
96
+ if (
97
+ !Array.isArray(entry.events) ||
98
+ entry.events.length === 0 ||
99
+ entry.events.some(
100
+ (event) => !["insert", "update", "delete"].includes(event),
101
+ )
102
+ ) {
103
+ throw new Error(`${label}.events are unsupported.`);
104
+ }
105
+ return Object.freeze({
106
+ name: id(entry.name, `${label}.name`),
107
+ table: relation(entry.table, `${label}.table`),
108
+ timing: entry.timing,
109
+ events: Object.freeze([...new Set(entry.events)]),
110
+ function: relation(entry.function, `${label}.function`),
111
+ });
112
+ });
113
+ const localRows = (policy.localRows ?? []).map((entry, index) => {
114
+ const label = `runtimePolicy.localRows[${index}]`;
115
+ object(entry, label);
116
+ keys(
117
+ entry,
118
+ ["table", "keyColumns", "values", "identityAssociation"],
119
+ label,
120
+ );
121
+ const table = relation(entry.table, `${label}.table`);
122
+ object(entry.values, `${label}.values`);
123
+ const valueColumns = Object.keys(entry.values).map((name) =>
124
+ id(name, `${label}.values key`),
125
+ );
126
+ if (
127
+ !Array.isArray(entry.keyColumns) ||
128
+ entry.keyColumns.length === 0 ||
129
+ entry.keyColumns.some((name) => !valueColumns.includes(name))
130
+ ) {
131
+ throw new Error(`${label}.keyColumns must be present in values.`);
132
+ }
133
+ let identityAssociation = null;
134
+ if (entry.identityAssociation != null) {
135
+ const associationLabel = `${label}.identityAssociation`;
136
+ object(entry.identityAssociation, associationLabel);
137
+ keys(entry.identityAssociation, ["identity", "column"], associationLabel);
138
+ if (!IDENTITY_NAME.test(entry.identityAssociation.identity ?? "")) {
139
+ throw new Error(`${associationLabel}.identity is invalid.`);
140
+ }
141
+ const column = id(
142
+ entry.identityAssociation.column,
143
+ `${associationLabel}.column`,
144
+ );
145
+ if (!entry.keyColumns.includes(column)) {
146
+ throw new Error(`${associationLabel}.column must be a key column.`);
147
+ }
148
+ if (!UUID.test(entry.values[column] ?? "")) {
149
+ throw new Error(
150
+ `${associationLabel}.column must contain the placeholder UUID.`,
151
+ );
152
+ }
153
+ identityAssociation = Object.freeze({
154
+ identity: entry.identityAssociation.identity,
155
+ column,
156
+ });
157
+ }
158
+ return Object.freeze({
159
+ table,
160
+ keyColumns: Object.freeze(entry.keyColumns),
161
+ values: entry.values,
162
+ identityAssociation,
163
+ });
164
+ });
165
+ const expectations = (policy.expectations ?? []).map((entry, index) => {
166
+ const label = `runtimePolicy.expectations[${index}]`;
167
+ object(entry, label);
168
+ keys(
169
+ entry,
170
+ ["table", "rowLevelSecurity", "columns", "foreignKeys", "policies"],
171
+ label,
172
+ );
173
+ if (typeof entry.rowLevelSecurity !== "boolean")
174
+ throw new Error(`${label}.rowLevelSecurity must be true or false.`);
175
+ if (!Array.isArray(entry.columns) || entry.columns.length === 0)
176
+ throw new Error(`${label}.columns must not be empty.`);
177
+ const foreignKeys = (entry.foreignKeys ?? []).map((foreignKey, fkIndex) => {
178
+ const fkLabel = `${label}.foreignKeys[${fkIndex}]`;
179
+ object(foreignKey, fkLabel);
180
+ keys(foreignKey, ["columns", "references"], fkLabel);
181
+ if (
182
+ !Array.isArray(foreignKey.columns) ||
183
+ foreignKey.columns.length === 0
184
+ ) {
185
+ throw new Error(`${fkLabel}.columns must not be empty.`);
186
+ }
187
+ object(foreignKey.references, `${fkLabel}.references`);
188
+ keys(
189
+ foreignKey.references,
190
+ ["schema", "table", "columns"],
191
+ `${fkLabel}.references`,
192
+ );
193
+ if (
194
+ !Array.isArray(foreignKey.references.columns) ||
195
+ foreignKey.references.columns.length !== foreignKey.columns.length
196
+ ) {
197
+ throw new Error(
198
+ `${fkLabel}.references.columns must match the local columns.`,
199
+ );
200
+ }
201
+ return Object.freeze({
202
+ columns: Object.freeze(
203
+ foreignKey.columns.map((column) =>
204
+ id(column, `${fkLabel}.columns[]`),
205
+ ),
206
+ ),
207
+ references: Object.freeze({
208
+ schema: id(
209
+ foreignKey.references.schema,
210
+ `${fkLabel}.references.schema`,
211
+ ),
212
+ table: id(foreignKey.references.table, `${fkLabel}.references.table`),
213
+ columns: Object.freeze(
214
+ foreignKey.references.columns.map((column) =>
215
+ id(column, `${fkLabel}.references.columns[]`),
216
+ ),
217
+ ),
218
+ }),
219
+ });
220
+ });
221
+ const policies = (entry.policies ?? []).map((rowPolicy, policyIndex) => {
222
+ const policyLabel = `${label}.policies[${policyIndex}]`;
223
+ object(rowPolicy, policyLabel);
224
+ keys(rowPolicy, ["name", "command", "roles"], policyLabel);
225
+ if (
226
+ !["ALL", "SELECT", "INSERT", "UPDATE", "DELETE"].includes(
227
+ rowPolicy.command,
228
+ )
229
+ ) {
230
+ throw new Error(`${policyLabel}.command is unsupported.`);
231
+ }
232
+ if (!Array.isArray(rowPolicy.roles) || rowPolicy.roles.length === 0) {
233
+ throw new Error(`${policyLabel}.roles must not be empty.`);
234
+ }
235
+ return Object.freeze({
236
+ name: id(rowPolicy.name, `${policyLabel}.name`),
237
+ command: rowPolicy.command,
238
+ roles: Object.freeze(
239
+ rowPolicy.roles.map((role) => id(role, `${policyLabel}.roles[]`)),
240
+ ),
241
+ });
242
+ });
243
+ return Object.freeze({
244
+ table: relation(entry.table, `${label}.table`),
245
+ rowLevelSecurity: entry.rowLevelSecurity,
246
+ columns: Object.freeze(
247
+ entry.columns.map((column, columnIndex) => {
248
+ const columnLabel = `${label}.columns[${columnIndex}]`;
249
+ object(column, columnLabel);
250
+ keys(column, ["name", "generated", "identity"], columnLabel);
251
+ return Object.freeze({
252
+ name: id(column.name, `${columnLabel}.name`),
253
+ generated: column.generated === true,
254
+ identity: column.identity === true,
255
+ });
256
+ }),
257
+ ),
258
+ foreignKeys: Object.freeze(foreignKeys),
259
+ policies: Object.freeze(policies),
260
+ });
261
+ });
262
+ return Object.freeze({
263
+ policyVersion: 1,
264
+ prerequisites: Object.freeze({
265
+ schemas: Object.freeze(schemas),
266
+ extensions: Object.freeze(extensions),
267
+ }),
268
+ triggers: Object.freeze(triggers),
269
+ localRows: Object.freeze(localRows),
270
+ expectations: Object.freeze(expectations),
271
+ });
272
+ };
273
+
274
+ export const buildRuntimePrerequisiteSql = (input) => {
275
+ const policy = validateRuntimePolicy(input);
276
+ const statements = ["begin;"];
277
+ for (const schema of policy.prerequisites.schemas) {
278
+ statements.push(`create schema if not exists ${qid(schema)};`);
279
+ }
280
+ for (const extension of policy.prerequisites.extensions) {
281
+ statements.push(
282
+ `create extension if not exists ${qext(extension.name)} with schema ${qid(extension.schema)};`,
283
+ );
284
+ }
285
+ statements.push("commit;");
286
+ return `${statements.join("\n")}\n`;
287
+ };
288
+
289
+ export const buildRuntimePostSchemaSql = (input) => {
290
+ const policy = validateRuntimePolicy(input);
291
+ const statements = ["begin;"];
292
+ for (const trigger of policy.triggers) {
293
+ const target = `${qid(trigger.table.schema)}.${qid(trigger.table.name)}`;
294
+ const fn = `${qid(trigger.function.schema)}.${qid(trigger.function.name)}`;
295
+ statements.push(`do $rehearsal_trigger$
296
+ begin
297
+ if not exists (
298
+ select 1 from pg_trigger
299
+ where tgname = ${literal(trigger.name)}
300
+ and tgrelid = ${literal(`${trigger.table.schema}.${trigger.table.name}`)}::regclass
301
+ and not tgisinternal
302
+ ) then
303
+ execute ${literal(
304
+ `create trigger ${qid(trigger.name)} ${trigger.timing} ${trigger.events.join(" or ")} on ${target} for each row execute function ${fn}()`,
305
+ )};
306
+ end if;
307
+ end
308
+ $rehearsal_trigger$;`);
309
+ }
310
+ statements.push("commit;");
311
+ return `${statements.join("\n")}\n`;
312
+ };
313
+
314
+ export const buildRuntimePreparationSql = (input) =>
315
+ `${buildRuntimePrerequisiteSql(input)}${buildRuntimePostSchemaSql(input)}`;
316
+
317
+ export const buildRuntimeFinalizationSql = (input) => {
318
+ const policy = validateRuntimePolicy(input);
319
+ const statements = ["begin;"];
320
+ if (policy.localRows.some((row) => row.identityAssociation)) {
321
+ statements.push(IDENTITY_CLAIM_RECEIPT_SQL);
322
+ }
323
+ for (const row of policy.localRows) {
324
+ const target = `${qid(row.table.schema)}.${qid(row.table.name)}`;
325
+ const columns = Object.keys(row.values);
326
+ statements.push(
327
+ `insert into ${target} (${columns.map(qid).join(", ")})
328
+ select ${columns.map((name) => `restored.${qid(name)}`).join(", ")}
329
+ from jsonb_populate_record(null::${target}, ${literal(JSON.stringify(row.values))}::jsonb) restored
330
+ on conflict (${row.keyColumns.map(qid).join(", ")}) do nothing;`,
331
+ );
332
+ }
333
+ statements.push("commit;");
334
+ return `${statements.join("\n")}\n`;
335
+ };
336
+
337
+ export const buildRuntimeVerificationSql = (input) => {
338
+ const policy = validateRuntimePolicy(input);
339
+ const checks = [];
340
+ for (const extension of policy.prerequisites.extensions) {
341
+ checks.push(`if not exists (
342
+ select 1 from pg_extension e join pg_namespace n on n.oid = e.extnamespace
343
+ where e.extname = ${literal(extension.name)} and n.nspname = ${literal(extension.schema)}
344
+ ) then raise exception 'Rehearsal extension prerequisite is missing'; end if;`);
345
+ }
346
+ for (const trigger of policy.triggers) {
347
+ const triggerType =
348
+ 1 +
349
+ (trigger.timing === "before" ? 2 : 0) +
350
+ (trigger.events.includes("insert") ? 4 : 0) +
351
+ (trigger.events.includes("delete") ? 8 : 0) +
352
+ (trigger.events.includes("update") ? 16 : 0);
353
+ checks.push(`if not exists (
354
+ select 1 from pg_trigger
355
+ where tgname = ${literal(trigger.name)}
356
+ and tgrelid = ${literal(`${trigger.table.schema}.${trigger.table.name}`)}::regclass
357
+ and tgfoid = ${literal(`${trigger.function.schema}.${trigger.function.name}()`)}::regprocedure
358
+ and tgtype = ${triggerType}
359
+ and not tgisinternal
360
+ ) then raise exception 'Rehearsal managed trigger is missing'; end if;`);
361
+ }
362
+ for (const row of policy.localRows) {
363
+ const target = `${qid(row.table.schema)}.${qid(row.table.name)}`;
364
+ checks.push(`if not exists (
365
+ select 1
366
+ from ${target} current_row,
367
+ jsonb_populate_record(null::${target}, ${literal(JSON.stringify(row.values))}::jsonb) expected
368
+ where ${row.keyColumns
369
+ .map((column) =>
370
+ row.identityAssociation?.column === column
371
+ ? `current_row.${qid(column)}::text = coalesce(
372
+ (select claim.local_user_id::text
373
+ from rehearsal_internal.identity_claims claim
374
+ where claim.identity_name = ${literal(row.identityAssociation.identity)}
375
+ and claim.placeholder_user_id::text = expected.${qid(column)}::text),
376
+ expected.${qid(column)}::text
377
+ )`
378
+ : `current_row.${qid(column)} is not distinct from expected.${qid(column)}`,
379
+ )
380
+ .join(" and ")}
381
+ ) then raise exception 'Rehearsal local-only row is missing'; end if;`);
382
+ }
383
+ for (const expectation of policy.expectations) {
384
+ const name = `${expectation.table.schema}.${expectation.table.name}`;
385
+ checks.push(`if not exists (
386
+ select 1 from pg_class c join pg_namespace n on n.oid = c.relnamespace
387
+ where n.nspname = ${literal(expectation.table.schema)}
388
+ and c.relname = ${literal(expectation.table.name)}
389
+ and c.relrowsecurity = ${expectation.rowLevelSecurity ? "true" : "false"}
390
+ ) then raise exception 'Rehearsal table or RLS expectation failed'; end if;`);
391
+ for (const column of expectation.columns) {
392
+ checks.push(`if not exists (
393
+ select 1 from information_schema.columns
394
+ where table_schema = ${literal(expectation.table.schema)}
395
+ and table_name = ${literal(expectation.table.name)}
396
+ and column_name = ${literal(column.name)}
397
+ and (is_generated <> 'NEVER') = ${column.generated ? "true" : "false"}
398
+ and (is_identity = 'YES') = ${column.identity ? "true" : "false"}
399
+ ) then raise exception ${literal(`Rehearsal column expectation failed for ${name}.${column.name}`)}; end if;`);
400
+ }
401
+ for (const foreignKey of expectation.foreignKeys) {
402
+ const localColumns = `array[${foreignKey.columns.map(literal).join(", ")}]::text[]`;
403
+ const targetColumns = `array[${foreignKey.references.columns.map(literal).join(", ")}]::text[]`;
404
+ checks.push(`if not exists (
405
+ select 1 from pg_constraint c
406
+ where c.contype = 'f'
407
+ and c.conrelid = ${literal(name)}::regclass
408
+ and c.confrelid = ${literal(
409
+ `${foreignKey.references.schema}.${foreignKey.references.table}`,
410
+ )}::regclass
411
+ and (select array_agg(a.attname order by u.ordinality)::text[]
412
+ from unnest(c.conkey) with ordinality u(attnum, ordinality)
413
+ join pg_attribute a on a.attrelid = c.conrelid and a.attnum = u.attnum) = ${localColumns}
414
+ and (select array_agg(a.attname order by u.ordinality)::text[]
415
+ from unnest(c.confkey) with ordinality u(attnum, ordinality)
416
+ join pg_attribute a on a.attrelid = c.confrelid and a.attnum = u.attnum) = ${targetColumns}
417
+ ) then raise exception 'Rehearsal foreign-key expectation failed'; end if;`);
418
+ }
419
+ for (const rowPolicy of expectation.policies) {
420
+ checks.push(`if not exists (
421
+ select 1 from pg_policies
422
+ where schemaname = ${literal(expectation.table.schema)}
423
+ and tablename = ${literal(expectation.table.name)}
424
+ and policyname = ${literal(rowPolicy.name)}
425
+ and cmd = ${literal(rowPolicy.command)}
426
+ and roles @> array[${rowPolicy.roles.map(literal).join(", ")}]::name[]
427
+ ) then raise exception 'Rehearsal row-policy expectation failed'; end if;`);
428
+ }
429
+ }
430
+ checks.push(`if exists (select 1 from pg_constraint where not convalidated) then
431
+ raise exception 'Rehearsal found an unvalidated structural constraint'; end if;`);
432
+ return `do $rehearsal_verify$
433
+ begin
434
+ ${checks.join("\n ")}
435
+ end
436
+ $rehearsal_verify$;
437
+ `;
438
+ };
@@ -18,6 +18,11 @@ const quoteIdentifier = (value) => {
18
18
  return `"${value}"`;
19
19
  };
20
20
  const quoteLiteral = (value) => `'${value.replaceAll("'", "''")}'`;
21
+ const tableSchema = (table) => table.schema ?? "public";
22
+ const relationKey = (schema, table) =>
23
+ schema === "public" ? table : `${schema}.${table}`;
24
+ const qualifiedRelation = (table) =>
25
+ `${quoteIdentifier(tableSchema(table))}.${quoteIdentifier(table.name)}`;
21
26
 
22
27
  const includedPolicies = (manifest) =>
23
28
  manifest.tables
@@ -47,6 +52,7 @@ export const encodeBaselineRecordForCopy = (line) => {
47
52
  });
48
53
  }
49
54
  if (
55
+ (record?.schema !== undefined && !identifierPattern.test(record.schema)) ||
50
56
  !identifierPattern.test(record?.table ?? "") ||
51
57
  !record.row ||
52
58
  typeof record.row !== "object" ||
@@ -56,7 +62,7 @@ export const encodeBaselineRecordForCopy = (line) => {
56
62
  "A Rehearsal baseline record has an invalid restore shape.",
57
63
  );
58
64
  }
59
- return `${record.table}\t${Buffer.from(JSON.stringify(record.row), "utf8").toString("base64")}`;
65
+ return `${record.schema ?? "public"}\t${record.table}\t${Buffer.from(JSON.stringify(record.row), "utf8").toString("base64")}`;
60
66
  };
61
67
 
62
68
  export const buildRestoreSqlPrefix = () => `\\set ON_ERROR_STOP on
@@ -65,10 +71,11 @@ begin;
65
71
  set local session_replication_role = replica;
66
72
  create temporary table rehearsal_restore_rows (
67
73
  sequence bigint generated always as identity primary key,
74
+ schema_name text not null,
68
75
  table_name text not null,
69
76
  encoded text not null
70
77
  ) on commit drop;
71
- copy rehearsal_restore_rows (table_name, encoded) from stdin;
78
+ copy rehearsal_restore_rows (schema_name, table_name, encoded) from stdin;
72
79
  `;
73
80
 
74
81
  const decodedPayloadSql =
@@ -89,7 +96,8 @@ const authReferenceSelects = (manifest) =>
89
96
  column,
90
97
  ) => `select nullif(${decodedPayloadSql}->>${quoteLiteral(column.name)}, '')::uuid as id
91
98
  from rehearsal_restore_rows source
92
- where source.table_name = ${quoteLiteral(table.name)}`,
99
+ where source.schema_name = ${quoteLiteral(tableSchema(table))}
100
+ and source.table_name = ${quoteLiteral(table.name)}`,
93
101
  ),
94
102
  );
95
103
 
@@ -147,7 +155,7 @@ on conflict (id) do nothing;
147
155
  };
148
156
 
149
157
  const buildTableRestoreSql = (table) => {
150
- const tableName = quoteIdentifier(table.name);
158
+ const target = qualifiedRelation(table);
151
159
  const columns = table.columns.filter(
152
160
  (column) => column.action !== "EXCLUDE" && column.generated !== "ALWAYS",
153
161
  );
@@ -161,14 +169,15 @@ const buildTableRestoreSql = (table) => {
161
169
  const overridesIdentity = columns.some((column) => column.identity === "YES")
162
170
  ? " overriding system value"
163
171
  : "";
164
- return `insert into public.${tableName} (${columnList})${overridesIdentity}
172
+ return `insert into ${target} (${columnList})${overridesIdentity}
165
173
  select ${selectedColumns}
166
174
  from rehearsal_restore_rows source
167
175
  cross join lateral jsonb_populate_record(
168
- null::public.${tableName},
176
+ null::${target},
169
177
  ${decodedPayloadSql}
170
178
  ) restored
171
- where source.table_name = ${quoteLiteral(table.name)}
179
+ where source.schema_name = ${quoteLiteral(tableSchema(table))}
180
+ and source.table_name = ${quoteLiteral(table.name)}
172
181
  order by source.sequence;
173
182
  `;
174
183
  };
@@ -182,26 +191,31 @@ const buildIdentitySequenceSql = (table) =>
182
191
  column.generated !== "ALWAYS",
183
192
  )
184
193
  .map((column) => {
185
- const relation = `public.${quoteIdentifier(table.name)}`;
194
+ const schema = tableSchema(table);
195
+ const relation = qualifiedRelation(table);
186
196
  const columnName = quoteIdentifier(column.name);
187
197
  return `select setval(
188
- pg_get_serial_sequence(${quoteLiteral(`public.${table.name}`)}, ${quoteLiteral(column.name)}),
198
+ pg_get_serial_sequence(${quoteLiteral(`${schema}.${table.name}`)}, ${quoteLiteral(column.name)}),
189
199
  coalesce((select max(${columnName}) from ${relation}), 1),
190
200
  exists(select 1 from ${relation})
191
201
  );`;
192
202
  })
193
203
  .join("\n");
194
204
 
195
- const buildRowCountChecks = (tableCounts) =>
196
- Object.entries(tableCounts)
197
- .sort(([left], [right]) => left.localeCompare(right))
198
- .map(([table, expected]) => {
199
- quoteIdentifier(table);
205
+ const buildRowCountChecks = (tables, tableCounts) =>
206
+ tables
207
+ .map((table) => ({
208
+ table,
209
+ key: relationKey(tableSchema(table), table.name),
210
+ expected: tableCounts[relationKey(tableSchema(table), table.name)],
211
+ }))
212
+ .sort((left, right) => left.key.localeCompare(right.key))
213
+ .map(({ table, key, expected }) => {
200
214
  if (!Number.isSafeInteger(expected) || expected < 0) {
201
- throw new Error(`Invalid Rehearsal row count for ${table}.`);
215
+ throw new Error(`Invalid Rehearsal row count for ${key}.`);
202
216
  }
203
- return `if (select count(*) from public.${quoteIdentifier(table)}) <> ${expected} then
204
- raise exception 'Rehearsal row-count mismatch for ${table}';
217
+ return `if (select count(*) from ${qualifiedRelation(table)}) <> ${expected} then
218
+ raise exception 'Rehearsal row-count mismatch for ${key}';
205
219
  end if;`;
206
220
  })
207
221
  .join("\n\t");
@@ -218,6 +232,7 @@ const buildForeignKeyChecks = (manifest) =>
218
232
  for (const value of [
219
233
  table.name,
220
234
  column.name,
235
+ tableSchema(table),
221
236
  target.schema,
222
237
  target.table,
223
238
  target.column,
@@ -225,13 +240,13 @@ const buildForeignKeyChecks = (manifest) =>
225
240
  quoteIdentifier(value);
226
241
  return `if exists (
227
242
  select 1
228
- from public.${quoteIdentifier(table.name)} child
243
+ from ${qualifiedRelation(table)} child
229
244
  left join ${quoteIdentifier(target.schema)}.${quoteIdentifier(target.table)} parent
230
245
  on parent.${quoteIdentifier(target.column)} = child.${quoteIdentifier(column.name)}
231
246
  where child.${quoteIdentifier(column.name)} is not null
232
247
  and parent.${quoteIdentifier(target.column)} is null
233
248
  ) then
234
- raise exception 'Rehearsal foreign-key mismatch for ${table.name}.${column.name}';
249
+ raise exception 'Rehearsal foreign-key mismatch for ${relationKey(tableSchema(table), table.name)}.${column.name}';
235
250
  end if;`;
236
251
  })
237
252
  .join("\n\t");
@@ -245,16 +260,16 @@ export const buildRestoreSqlSuffix = ({
245
260
  throw new Error("A reviewed manifest and exact row counts are required.");
246
261
  }
247
262
  const tables = includedPolicies(manifest);
248
- const expectedNames = tables.map((table) => table.name).sort();
263
+ const expectedNames = tables
264
+ .map((table) => relationKey(tableSchema(table), table.name))
265
+ .sort();
249
266
  if (expectedNames.join("\0") !== Object.keys(tableCounts).sort().join("\0")) {
250
267
  throw new Error("Rehearsal restore table counts do not match the policy.");
251
268
  }
252
269
  return `\\.
253
270
  create index rehearsal_restore_rows_table_name_idx
254
- on rehearsal_restore_rows (table_name, sequence);
255
- truncate table ${manifest.tables
256
- .map((table) => `public.${quoteIdentifier(table.name)}`)
257
- .join(", ")}
271
+ on rehearsal_restore_rows (schema_name, table_name, sequence);
272
+ truncate table ${manifest.tables.map(qualifiedRelation).join(", ")}
258
273
  restart identity cascade;
259
274
  ${restoreSupabaseAuth ? buildAuthRestoreSql(manifest) : ""}
260
275
  ${tables.map(buildTableRestoreSql).join("\n")}
@@ -262,7 +277,7 @@ ${tables.map(buildIdentitySequenceSql).filter(Boolean).join("\n")}
262
277
  set local session_replication_role = origin;
263
278
  do $rehearsal_checks$
264
279
  begin
265
- ${buildRowCountChecks(tableCounts)}
280
+ ${buildRowCountChecks(tables, tableCounts)}
266
281
  ${buildForeignKeyChecks(manifest)}
267
282
  end
268
283
  $rehearsal_checks$;