@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.
- package/CHANGELOG.md +77 -1
- package/COMPATIBILITY.md +15 -3
- package/README.md +31 -13
- package/docs/README.md +29 -0
- package/docs/adapters.md +15 -6
- package/docs/architecture.md +58 -0
- package/docs/baselines.md +22 -1
- package/docs/commands.md +81 -14
- package/docs/configuration.md +144 -11
- package/docs/getting-started.md +12 -2
- package/docs/glossary.md +4 -0
- package/docs/production-source.md +176 -33
- package/docs/roadmap.md +30 -28
- package/docs/runtime-policies.md +191 -0
- package/docs/sanitization.md +24 -8
- package/docs/security-model.md +29 -8
- package/docs/standalone-workflow.md +107 -0
- package/docs/troubleshooting.md +8 -0
- package/package.json +25 -22
- package/scripts/runtime/manage_database.mjs +18 -0
- package/src/README.md +17 -0
- package/src/application/session.mjs +313 -0
- package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
- package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
- package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
- package/src/baseline/privacy_engine.mjs +413 -0
- package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
- package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
- package/src/cli/arguments.mjs +120 -0
- package/src/cli/guided.mjs +807 -0
- package/src/cli/rehearsal.mjs +933 -0
- package/src/cli/renderers.mjs +584 -0
- package/src/cli/runtime_commands.mjs +553 -0
- package/src/cli/source_commands.mjs +326 -0
- package/src/cli/terminal.mjs +275 -0
- package/src/identity/claim.mjs +975 -0
- package/src/identity/storage.mjs +165 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +34 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
- package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
- package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
- package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
- package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
- package/src/runtime/policy.mjs +438 -0
- package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
- package/src/runtime/topology.mjs +177 -0
- package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
- package/src/shared/operation_guard.mjs +162 -0
- package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
- package/src/source/access.mjs +578 -0
- package/src/source/asset_transfer.mjs +177 -0
- package/src/source/baseline.mjs +446 -0
- package/src/source/postgresql_access.mjs +480 -0
- package/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
- package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
- package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
- package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
- package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
- /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
- /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 "
|
|
13
|
-
import { loadRehearsalConfig } from "
|
|
14
|
-
import { createCleanProcessEnvironment } from "
|
|
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 "
|
|
13
|
+
import { verifyActiveBaseline } from "../baseline/artifact.mjs";
|
|
14
14
|
import {
|
|
15
15
|
inspectDetectedProject,
|
|
16
16
|
loadRehearsalConfig,
|
|
17
|
-
} from "
|
|
18
|
-
import { createCandidateMigrationReceipt } from "./
|
|
17
|
+
} from "../project/configuration.mjs";
|
|
18
|
+
import { createCandidateMigrationReceipt } from "./restore.mjs";
|
|
19
19
|
import { readMigrationFileInventory } from "./migration_history.mjs";
|
|
20
|
-
import { REHEARSAL_VERSION } from "
|
|
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 "
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
176
|
+
null::${target},
|
|
169
177
|
${decodedPayloadSql}
|
|
170
178
|
) restored
|
|
171
|
-
where source.
|
|
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
|
|
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(
|
|
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
|
-
|
|
197
|
-
.
|
|
198
|
-
|
|
199
|
-
|
|
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 ${
|
|
215
|
+
throw new Error(`Invalid Rehearsal row count for ${key}.`);
|
|
202
216
|
}
|
|
203
|
-
return `if (select count(*) from
|
|
204
|
-
raise exception 'Rehearsal row-count mismatch for ${
|
|
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
|
|
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
|
|
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
|
-
|
|
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$;
|