@rehearsal-db/core 0.1.0-beta.13 → 0.1.0-beta.15
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 +93 -0
- package/README.md +4 -1
- package/dist/package.json +100 -0
- package/dist/scripts/install/create_root_config.d.mts +3 -0
- package/dist/scripts/install/create_root_config.mjs +13 -0
- package/dist/scripts/runtime/manage_database.d.mts +6 -0
- package/{scripts → dist/scripts}/runtime/manage_database.mjs +6 -8
- package/dist/src/application/session.d.mts +96 -0
- package/dist/src/application/session.mjs +380 -0
- package/dist/src/baseline/artifact.d.mts +119 -0
- package/dist/src/baseline/artifact.mjs +522 -0
- package/dist/src/baseline/builder.d.mts +19 -0
- package/dist/src/baseline/builder.mjs +142 -0
- package/dist/src/baseline/input_discovery.d.mts +13 -0
- package/dist/src/baseline/input_discovery.mjs +149 -0
- package/dist/src/baseline/policy_review.d.mts +56 -0
- package/dist/src/baseline/policy_review.mjs +73 -0
- package/dist/src/baseline/preparation.d.mts +63 -0
- package/dist/src/baseline/preparation.mjs +295 -0
- package/dist/src/baseline/privacy_engine.d.mts +162 -0
- package/dist/src/baseline/privacy_engine.mjs +1162 -0
- package/dist/src/baseline/privacy_structured_json.d.mts +66 -0
- package/dist/src/baseline/privacy_structured_json.mjs +464 -0
- package/dist/src/baseline/sanitization_policy.d.mts +69 -0
- package/dist/src/baseline/sanitization_policy.mjs +227 -0
- package/dist/src/baseline/schema_snapshot.d.mts +19 -0
- package/dist/src/baseline/schema_snapshot.mjs +79 -0
- package/dist/src/cli/arguments.d.mts +43 -0
- package/dist/src/cli/arguments.mjs +135 -0
- package/dist/src/cli/guided.d.mts +51 -0
- package/dist/src/cli/guided.mjs +713 -0
- package/dist/src/cli/init_command.d.mts +21 -0
- package/dist/src/cli/init_command.mjs +90 -0
- package/dist/src/cli/project_commands.d.mts +157 -0
- package/dist/src/cli/project_commands.mjs +183 -0
- package/dist/src/cli/rehearsal.d.mts +7 -0
- package/dist/src/cli/rehearsal.mjs +706 -0
- package/dist/src/cli/renderers.d.mts +299 -0
- package/dist/src/cli/renderers.mjs +554 -0
- package/dist/src/cli/runtime_commands.d.mts +265 -0
- package/dist/src/cli/runtime_commands.mjs +599 -0
- package/dist/src/cli/source_commands.d.mts +432 -0
- package/dist/src/cli/source_commands.mjs +299 -0
- package/dist/src/cli/terminal.d.mts +59 -0
- package/dist/src/cli/terminal.mjs +230 -0
- package/dist/src/identity/claim.d.mts +144 -0
- package/dist/src/identity/claim.mjs +947 -0
- package/dist/src/identity/provider_configuration.d.mts +37 -0
- package/dist/src/identity/provider_configuration.mjs +132 -0
- package/dist/src/identity/storage.d.mts +21 -0
- package/dist/src/identity/storage.mjs +122 -0
- package/dist/src/index.d.mts +4 -0
- package/dist/src/index.mjs +3 -0
- package/dist/src/project/configuration.d.mts +181 -0
- package/dist/src/project/configuration.mjs +958 -0
- package/dist/src/project/install_scaffold.d.mts +42 -0
- package/dist/src/project/install_scaffold.mjs +263 -0
- package/dist/src/project/setup.d.mts +87 -0
- package/dist/src/project/setup.mjs +414 -0
- package/dist/src/project/support_report.d.mts +88 -0
- package/dist/src/project/support_report.mjs +83 -0
- package/dist/src/runtime/cleanup.d.mts +85 -0
- package/dist/src/runtime/cleanup.mjs +311 -0
- package/dist/src/runtime/migration_history.d.mts +51 -0
- package/dist/src/runtime/migration_history.mjs +170 -0
- package/dist/src/runtime/plan.d.mts +134 -0
- package/dist/src/runtime/plan.mjs +636 -0
- package/dist/src/runtime/policy.d.mts +66 -0
- package/dist/src/runtime/policy.mjs +369 -0
- package/dist/src/runtime/restore.d.mts +22 -0
- package/dist/src/runtime/restore.mjs +276 -0
- package/dist/src/runtime/service_environment.d.mts +11 -0
- package/dist/src/runtime/service_environment.mjs +58 -0
- package/dist/src/runtime/topology.d.mts +51 -0
- package/dist/src/runtime/topology.mjs +128 -0
- package/dist/src/shared/diagnostics.d.mts +97 -0
- package/dist/src/shared/diagnostics.mjs +170 -0
- package/dist/src/shared/human_output.d.mts +2 -0
- package/{src → dist/src}/shared/human_output.mjs +1 -3
- package/dist/src/shared/node_runtime.d.mts +14 -0
- package/dist/src/shared/node_runtime.mjs +25 -0
- package/dist/src/shared/operation_guard.d.mts +22 -0
- package/dist/src/shared/operation_guard.mjs +150 -0
- package/dist/src/shared/process_environment.d.mts +15 -0
- package/dist/src/shared/process_environment.mjs +50 -0
- package/dist/src/source/access.d.mts +165 -0
- package/dist/src/source/access.mjs +602 -0
- package/dist/src/source/asset_transfer.d.mts +27 -0
- package/dist/src/source/asset_transfer.mjs +202 -0
- package/dist/src/source/baseline.d.mts +66 -0
- package/dist/src/source/baseline.mjs +372 -0
- package/dist/src/source/external_reader.d.mts +42 -0
- package/dist/src/source/external_reader.mjs +278 -0
- package/dist/src/source/postgresql_access.d.mts +86 -0
- package/dist/src/source/postgresql_access.mjs +454 -0
- package/dist/src/source/privacy_paths.d.mts +10 -0
- package/dist/src/source/privacy_paths.mjs +27 -0
- package/dist/src/targets/driver.d.mts +2 -0
- package/dist/src/targets/driver.mjs +13 -0
- package/dist/src/targets/postgresql.d.mts +5 -0
- package/dist/src/targets/postgresql.mjs +641 -0
- package/dist/src/targets/supabase.d.mts +8 -0
- package/dist/src/targets/supabase.mjs +659 -0
- package/dist/src/targets/supabase_environment.d.mts +49 -0
- package/dist/src/targets/supabase_environment.mjs +162 -0
- package/dist/src/targets/target.d.mts +33 -0
- package/dist/src/targets/target.mjs +39 -0
- package/docs/README.md +1 -0
- package/docs/architecture.md +17 -9
- package/docs/configuration.md +49 -8
- package/docs/getting-started.md +28 -0
- package/docs/index.md +1 -0
- package/docs/production-source.md +43 -7
- package/docs/releasing.md +5 -3
- package/docs/roadmap.md +3 -2
- package/docs/sanitization.md +415 -14
- package/docs/standalone-workflow.md +12 -6
- package/docs/troubleshooting.md +5 -1
- package/docs/tutorial.md +5 -0
- package/docs/typescript.md +87 -0
- package/package.json +35 -29
- package/scripts/install/create_root_config.mjs +0 -36
- package/src/README.md +0 -17
- package/src/application/session.mjs +0 -435
- package/src/baseline/artifact.mjs +0 -684
- package/src/baseline/builder.mjs +0 -164
- package/src/baseline/input_discovery.mjs +0 -155
- package/src/baseline/policy_review.mjs +0 -92
- package/src/baseline/preparation.mjs +0 -323
- package/src/baseline/privacy_engine.mjs +0 -952
- package/src/baseline/sanitization_policy.mjs +0 -299
- package/src/baseline/schema_snapshot.mjs +0 -113
- package/src/cli/arguments.mjs +0 -126
- package/src/cli/guided.mjs +0 -814
- package/src/cli/init_command.mjs +0 -94
- package/src/cli/rehearsal.mjs +0 -970
- package/src/cli/renderers.mjs +0 -626
- package/src/cli/runtime_commands.mjs +0 -707
- package/src/cli/source_commands.mjs +0 -347
- package/src/cli/terminal.mjs +0 -275
- package/src/identity/claim.mjs +0 -1163
- package/src/identity/provider_configuration.mjs +0 -177
- package/src/identity/storage.mjs +0 -165
- package/src/project/configuration.mjs +0 -1337
- package/src/project/install_scaffold.mjs +0 -291
- package/src/project/setup.mjs +0 -466
- package/src/project/support_report.mjs +0 -105
- package/src/runtime/cleanup.mjs +0 -381
- package/src/runtime/migration_history.mjs +0 -220
- package/src/runtime/plan.mjs +0 -722
- package/src/runtime/policy.mjs +0 -438
- package/src/runtime/restore.mjs +0 -337
- package/src/runtime/service_environment.mjs +0 -82
- package/src/runtime/topology.mjs +0 -177
- package/src/shared/diagnostics.mjs +0 -207
- package/src/shared/node_runtime.mjs +0 -31
- package/src/shared/operation_guard.mjs +0 -162
- package/src/shared/process_environment.mjs +0 -68
- package/src/source/access.mjs +0 -753
- package/src/source/asset_transfer.mjs +0 -207
- package/src/source/baseline.mjs +0 -482
- package/src/source/external_reader.mjs +0 -255
- package/src/source/postgresql_access.mjs +0 -581
- package/src/source/privacy_paths.mjs +0 -38
- package/src/targets/postgresql.mjs +0 -808
- package/src/targets/supabase.mjs +0 -840
- package/src/targets/supabase_environment.mjs +0 -229
- package/src/targets/target.mjs +0 -66
- /package/{src/project → types}/configuration.d.mts +0 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Build the bounded PostgreSQL restore program for a verified sanitized
|
|
3
|
+
* Rehearsal baseline. Do not run directly; this module is reusable script infrastructure.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
7
|
+
const constraintFailurePattern = /^ERROR:\s+(?:[A-Z0-9]{5}:\s+)?new row for relation "([a-z][a-z0-9_]*)" violates check constraint "([a-z][a-z0-9_]*)"$/mu;
|
|
8
|
+
const sqlStatePattern = /^ERROR:\s+([A-Z0-9]{5}):/mu;
|
|
9
|
+
const notNullFailurePattern = /^ERROR:\s+(?:23502:\s+)?null value in column "([a-z][a-z0-9_]*)" of relation "([a-z][a-z0-9_]*)" violates not-null constraint$/mu;
|
|
10
|
+
const quoteIdentifier = (value) => {
|
|
11
|
+
if (!identifierPattern.test(value)) {
|
|
12
|
+
throw new Error(`Unsafe Rehearsal restore identifier: ${value}`);
|
|
13
|
+
}
|
|
14
|
+
return `"${value}"`;
|
|
15
|
+
};
|
|
16
|
+
const quoteLiteral = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
17
|
+
const tableSchema = (table) => table.schema ?? "public";
|
|
18
|
+
const relationKey = (schema, table) => schema === "public" ? table : `${schema}.${table}`;
|
|
19
|
+
const qualifiedRelation = (table) => `${quoteIdentifier(tableSchema(table))}.${quoteIdentifier(table.name)}`;
|
|
20
|
+
const includedPolicies = (manifest) => manifest.tables
|
|
21
|
+
.filter((table) => table.sourceRows !== "EXCLUDE")
|
|
22
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
23
|
+
export const summarizeRestoreError = (stderr) => {
|
|
24
|
+
const match = String(stderr).match(constraintFailurePattern);
|
|
25
|
+
if (match)
|
|
26
|
+
return `Sanitized data violated ${match[1]}.${match[2]}.`;
|
|
27
|
+
const notNull = String(stderr).match(notNullFailurePattern);
|
|
28
|
+
if (notNull) {
|
|
29
|
+
return `Sanitized data omitted required column ${notNull[2]}.${notNull[1]}.`;
|
|
30
|
+
}
|
|
31
|
+
const sqlState = String(stderr).match(sqlStatePattern)?.[1];
|
|
32
|
+
return sqlState
|
|
33
|
+
? `PostgreSQL rejected the sanitized baseline stream with SQLSTATE ${sqlState}; row details were withheld.`
|
|
34
|
+
: "PostgreSQL rejected the sanitized baseline stream; row details were withheld.";
|
|
35
|
+
};
|
|
36
|
+
export const encodeBaselineRecordForCopy = (line) => {
|
|
37
|
+
let record;
|
|
38
|
+
try {
|
|
39
|
+
record = JSON.parse(line.trimEnd());
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new Error("A Rehearsal baseline record is not valid JSON.", {
|
|
43
|
+
cause: error,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if ((record?.schema !== undefined && !identifierPattern.test(record.schema)) ||
|
|
47
|
+
!identifierPattern.test(record?.table ?? "") ||
|
|
48
|
+
!record.row ||
|
|
49
|
+
typeof record.row !== "object" ||
|
|
50
|
+
Array.isArray(record.row)) {
|
|
51
|
+
throw new Error("A Rehearsal baseline record has an invalid restore shape.");
|
|
52
|
+
}
|
|
53
|
+
return `${record.schema ?? "public"}\t${record.table}\t${Buffer.from(JSON.stringify(record.row), "utf8").toString("base64")}`;
|
|
54
|
+
};
|
|
55
|
+
export const buildRestoreSqlPrefix = () => `\\set ON_ERROR_STOP on
|
|
56
|
+
\\set VERBOSITY verbose
|
|
57
|
+
begin;
|
|
58
|
+
set local session_replication_role = replica;
|
|
59
|
+
create temporary table rehearsal_restore_rows (
|
|
60
|
+
sequence bigint generated always as identity primary key,
|
|
61
|
+
schema_name text not null,
|
|
62
|
+
table_name text not null,
|
|
63
|
+
encoded text not null
|
|
64
|
+
) on commit drop;
|
|
65
|
+
copy rehearsal_restore_rows (schema_name, table_name, encoded) from stdin;
|
|
66
|
+
`;
|
|
67
|
+
const decodedPayloadSql = "convert_from(decode(source.encoded, 'base64'), 'UTF8')::jsonb";
|
|
68
|
+
const authReferenceSelects = (manifest) => includedPolicies(manifest).flatMap((table) => table.columns
|
|
69
|
+
.filter((column) => column.action !== "EXCLUDE" &&
|
|
70
|
+
column.foreignKey?.schema === "auth" &&
|
|
71
|
+
column.foreignKey?.table === "users" &&
|
|
72
|
+
column.foreignKey?.column === "id")
|
|
73
|
+
.map((column) => `select nullif(${decodedPayloadSql}->>${quoteLiteral(column.name)}, '')::uuid as id
|
|
74
|
+
from rehearsal_restore_rows source
|
|
75
|
+
where source.schema_name = ${quoteLiteral(tableSchema(table))}
|
|
76
|
+
and source.table_name = ${quoteLiteral(table.name)}`));
|
|
77
|
+
const buildAuthRestoreSql = (manifest) => {
|
|
78
|
+
const selects = authReferenceSelects(manifest);
|
|
79
|
+
if (selects.length === 0)
|
|
80
|
+
return "";
|
|
81
|
+
return `with referenced_auth_users as (
|
|
82
|
+
${selects.join("\n\tunion\n\t")}
|
|
83
|
+
)
|
|
84
|
+
insert into auth.users (
|
|
85
|
+
instance_id,
|
|
86
|
+
id,
|
|
87
|
+
aud,
|
|
88
|
+
role,
|
|
89
|
+
email,
|
|
90
|
+
confirmation_token,
|
|
91
|
+
recovery_token,
|
|
92
|
+
email_change_token_new,
|
|
93
|
+
email_change,
|
|
94
|
+
email_change_token_current,
|
|
95
|
+
phone_change,
|
|
96
|
+
phone_change_token,
|
|
97
|
+
reauthentication_token,
|
|
98
|
+
raw_app_meta_data,
|
|
99
|
+
raw_user_meta_data,
|
|
100
|
+
is_sso_user,
|
|
101
|
+
is_anonymous,
|
|
102
|
+
created_at,
|
|
103
|
+
updated_at
|
|
104
|
+
)
|
|
105
|
+
select distinct
|
|
106
|
+
'00000000-0000-0000-0000-000000000000'::uuid,
|
|
107
|
+
id,
|
|
108
|
+
'authenticated',
|
|
109
|
+
'authenticated',
|
|
110
|
+
'rehearsal-' || replace(id::text, '-', '') || '@rehearsal.invalid',
|
|
111
|
+
'',
|
|
112
|
+
'',
|
|
113
|
+
'',
|
|
114
|
+
'',
|
|
115
|
+
'',
|
|
116
|
+
'',
|
|
117
|
+
'',
|
|
118
|
+
'',
|
|
119
|
+
'{"provider":"email","providers":["email"]}'::jsonb,
|
|
120
|
+
'{"rehearsal":true}'::jsonb,
|
|
121
|
+
false,
|
|
122
|
+
false,
|
|
123
|
+
now(),
|
|
124
|
+
now()
|
|
125
|
+
from referenced_auth_users
|
|
126
|
+
where id is not null
|
|
127
|
+
on conflict (id) do nothing;
|
|
128
|
+
`;
|
|
129
|
+
};
|
|
130
|
+
const buildTableRestoreSql = (table) => {
|
|
131
|
+
const target = qualifiedRelation(table);
|
|
132
|
+
const columns = table.columns.filter((column) => column.action !== "EXCLUDE" && column.generated !== "ALWAYS");
|
|
133
|
+
if (columns.length === 0)
|
|
134
|
+
return "";
|
|
135
|
+
const columnList = columns
|
|
136
|
+
.map((column) => quoteIdentifier(column.name))
|
|
137
|
+
.join(", ");
|
|
138
|
+
const selectedColumns = columns
|
|
139
|
+
.map((column) => `restored.${quoteIdentifier(column.name)}`)
|
|
140
|
+
.join(", ");
|
|
141
|
+
const overridesIdentity = columns.some((column) => column.identity === "YES")
|
|
142
|
+
? " overriding system value"
|
|
143
|
+
: "";
|
|
144
|
+
return `insert into ${target} (${columnList})${overridesIdentity}
|
|
145
|
+
select ${selectedColumns}
|
|
146
|
+
from rehearsal_restore_rows source
|
|
147
|
+
cross join lateral jsonb_populate_record(
|
|
148
|
+
null::${target},
|
|
149
|
+
${decodedPayloadSql}
|
|
150
|
+
) restored
|
|
151
|
+
where source.schema_name = ${quoteLiteral(tableSchema(table))}
|
|
152
|
+
and source.table_name = ${quoteLiteral(table.name)}
|
|
153
|
+
order by source.sequence;
|
|
154
|
+
`;
|
|
155
|
+
};
|
|
156
|
+
const buildIdentitySequenceSql = (table) => table.columns
|
|
157
|
+
.filter((column) => column.action !== "EXCLUDE" &&
|
|
158
|
+
column.identity === "YES" &&
|
|
159
|
+
column.generated !== "ALWAYS")
|
|
160
|
+
.map((column) => {
|
|
161
|
+
const schema = tableSchema(table);
|
|
162
|
+
const relation = qualifiedRelation(table);
|
|
163
|
+
const columnName = quoteIdentifier(column.name);
|
|
164
|
+
const sequenceValueQuery = `select max(${columnName})::bigint, exists(select 1 from ${relation}) from ${relation}`;
|
|
165
|
+
return `do $rehearsal_sequence$
|
|
166
|
+
declare
|
|
167
|
+
sequence_name text := pg_get_serial_sequence(${quoteLiteral(`${schema}.${table.name}`)}, ${quoteLiteral(column.name)});
|
|
168
|
+
maximum_value bigint;
|
|
169
|
+
has_rows boolean;
|
|
170
|
+
begin
|
|
171
|
+
if sequence_name is not null then
|
|
172
|
+
execute ${quoteLiteral(sequenceValueQuery)} into maximum_value, has_rows;
|
|
173
|
+
perform setval(sequence_name, coalesce(maximum_value, 1), has_rows);
|
|
174
|
+
end if;
|
|
175
|
+
end
|
|
176
|
+
$rehearsal_sequence$;`;
|
|
177
|
+
})
|
|
178
|
+
.join("\n");
|
|
179
|
+
const buildRowCountChecks = (tables, tableCounts) => tables
|
|
180
|
+
.map((table) => ({
|
|
181
|
+
table,
|
|
182
|
+
key: relationKey(tableSchema(table), table.name),
|
|
183
|
+
expected: tableCounts[relationKey(tableSchema(table), table.name)],
|
|
184
|
+
}))
|
|
185
|
+
.sort((left, right) => left.key.localeCompare(right.key))
|
|
186
|
+
.map(({ table, key, expected }) => {
|
|
187
|
+
if (typeof expected !== "number" ||
|
|
188
|
+
!Number.isSafeInteger(expected) ||
|
|
189
|
+
expected < 0) {
|
|
190
|
+
throw new Error(`Invalid Rehearsal row count for ${key}.`);
|
|
191
|
+
}
|
|
192
|
+
return `if (select count(*) from ${qualifiedRelation(table)}) <> ${expected} then
|
|
193
|
+
raise exception 'Rehearsal row-count mismatch for ${key}';
|
|
194
|
+
end if;`;
|
|
195
|
+
})
|
|
196
|
+
.join("\n\t");
|
|
197
|
+
const buildForeignKeyChecks = (manifest) => includedPolicies(manifest)
|
|
198
|
+
.flatMap((table) => table.columns.flatMap((column) => column.action !== "EXCLUDE" && column.foreignKey
|
|
199
|
+
? [{ table, column, target: column.foreignKey }]
|
|
200
|
+
: []))
|
|
201
|
+
.map(({ table, column, target }) => {
|
|
202
|
+
for (const value of [
|
|
203
|
+
table.name,
|
|
204
|
+
column.name,
|
|
205
|
+
tableSchema(table),
|
|
206
|
+
target.schema,
|
|
207
|
+
target.table,
|
|
208
|
+
target.column,
|
|
209
|
+
])
|
|
210
|
+
quoteIdentifier(value);
|
|
211
|
+
return `if exists (
|
|
212
|
+
select 1
|
|
213
|
+
from ${qualifiedRelation(table)} child
|
|
214
|
+
left join ${quoteIdentifier(target.schema)}.${quoteIdentifier(target.table)} parent
|
|
215
|
+
on parent.${quoteIdentifier(target.column)} = child.${quoteIdentifier(column.name)}
|
|
216
|
+
where child.${quoteIdentifier(column.name)} is not null
|
|
217
|
+
and parent.${quoteIdentifier(target.column)} is null
|
|
218
|
+
) then
|
|
219
|
+
raise exception 'Rehearsal foreign-key mismatch for ${relationKey(tableSchema(table), table.name)}.${column.name}';
|
|
220
|
+
end if;`;
|
|
221
|
+
})
|
|
222
|
+
.join("\n\t");
|
|
223
|
+
export const buildRestoreSqlSuffix = ({ manifest, tableCounts, restoreSupabaseAuth = true, }) => {
|
|
224
|
+
if (!Array.isArray(manifest?.tables) || !tableCounts) {
|
|
225
|
+
throw new Error("A reviewed manifest and exact row counts are required.");
|
|
226
|
+
}
|
|
227
|
+
const tables = includedPolicies(manifest);
|
|
228
|
+
const expectedNames = tables
|
|
229
|
+
.map((table) => relationKey(tableSchema(table), table.name))
|
|
230
|
+
.sort();
|
|
231
|
+
if (expectedNames.join("\0") !== Object.keys(tableCounts).sort().join("\0")) {
|
|
232
|
+
throw new Error("Rehearsal restore table counts do not match the policy.");
|
|
233
|
+
}
|
|
234
|
+
return `\\.
|
|
235
|
+
create index rehearsal_restore_rows_table_name_idx
|
|
236
|
+
on rehearsal_restore_rows (schema_name, table_name, sequence);
|
|
237
|
+
truncate table ${manifest.tables.map(qualifiedRelation).join(", ")}
|
|
238
|
+
restart identity cascade;
|
|
239
|
+
${restoreSupabaseAuth ? buildAuthRestoreSql(manifest) : ""}
|
|
240
|
+
${tables.map(buildTableRestoreSql).join("\n")}
|
|
241
|
+
${tables.map(buildIdentitySequenceSql).filter(Boolean).join("\n")}
|
|
242
|
+
set local session_replication_role = origin;
|
|
243
|
+
do $rehearsal_checks$
|
|
244
|
+
begin
|
|
245
|
+
${buildRowCountChecks(tables, tableCounts)}
|
|
246
|
+
${buildForeignKeyChecks(manifest)}
|
|
247
|
+
end
|
|
248
|
+
$rehearsal_checks$;
|
|
249
|
+
commit;
|
|
250
|
+
`;
|
|
251
|
+
};
|
|
252
|
+
export const createCandidateMigrationReceipt = ({ baselineManifest, currentFiles, }) => {
|
|
253
|
+
const baselineEntries = Object.entries(baselineManifest?.migrations ?? {}).sort(([left], [right]) => left.localeCompare(right));
|
|
254
|
+
if (baselineEntries.length === 0) {
|
|
255
|
+
throw new Error("The active Rehearsal baseline has no migration bundle.");
|
|
256
|
+
}
|
|
257
|
+
if (baselineEntries.length > currentFiles.length) {
|
|
258
|
+
throw new Error("The active Rehearsal history is absent from local source.");
|
|
259
|
+
}
|
|
260
|
+
for (let index = 0; index < baselineEntries.length; index += 1) {
|
|
261
|
+
const [filename, receipt] = baselineEntries[index];
|
|
262
|
+
const current = currentFiles[index];
|
|
263
|
+
if (current.filename !== filename ||
|
|
264
|
+
current.fileSha256 !== receipt.sha256) {
|
|
265
|
+
throw new Error(`Local migration history diverges from the active baseline at ${filename}.`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const candidates = currentFiles.slice(baselineEntries.length);
|
|
269
|
+
const candidateSha256 = createHash("sha256")
|
|
270
|
+
.update(`${JSON.stringify(candidates.map(({ filename, fileSha256 }) => ({
|
|
271
|
+
filename,
|
|
272
|
+
fileSha256,
|
|
273
|
+
})), null, "\t")}\n`)
|
|
274
|
+
.digest("hex");
|
|
275
|
+
return { candidates, candidateSha256 };
|
|
276
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Load an exact, project-owned allowlist of local service credentials
|
|
3
|
+
* without exposing values or inheriting ambient hosted credentials.
|
|
4
|
+
* Do not run directly; this module is reusable Rehearsal infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
export interface RehearsalServiceEnvironmentOptions {
|
|
7
|
+
path?: string | null;
|
|
8
|
+
keys?: readonly string[];
|
|
9
|
+
blockedKeys?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
export declare const readRehearsalServiceEnvironment: ({ path, keys, blockedKeys, }: RehearsalServiceEnvironmentOptions) => Promise<Record<string, string>>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Load an exact, project-owned allowlist of local service credentials
|
|
3
|
+
* without exposing values or inheriting ambient hosted credentials.
|
|
4
|
+
* Do not run directly; this module is reusable Rehearsal infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, stat } from "node:fs/promises";
|
|
7
|
+
import { parseEnv } from "node:util";
|
|
8
|
+
const ENVIRONMENT_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/u;
|
|
9
|
+
const hasErrorCode = (error, code) => typeof error === "object" &&
|
|
10
|
+
error !== null &&
|
|
11
|
+
"code" in error &&
|
|
12
|
+
error.code === code;
|
|
13
|
+
export const readRehearsalServiceEnvironment = async ({ path, keys = [], blockedKeys = [], }) => {
|
|
14
|
+
if (!path) {
|
|
15
|
+
if (keys.length) {
|
|
16
|
+
throw new Error("Rehearsal service environment keys require an owned environment file.");
|
|
17
|
+
}
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
if (!keys.length) {
|
|
21
|
+
throw new Error("A Rehearsal service environment file requires an exact variable allowlist.");
|
|
22
|
+
}
|
|
23
|
+
for (const key of keys) {
|
|
24
|
+
if (!ENVIRONMENT_KEY_PATTERN.test(key)) {
|
|
25
|
+
throw new Error(`Invalid Rehearsal service environment key: ${key}.`);
|
|
26
|
+
}
|
|
27
|
+
if (blockedKeys.includes(key)) {
|
|
28
|
+
throw new Error(`Rehearsal service environment key ${key} is blocked by the safety policy.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
let source;
|
|
32
|
+
let details;
|
|
33
|
+
try {
|
|
34
|
+
[details, source] = await Promise.all([stat(path), readFile(path, "utf8")]);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
38
|
+
throw new Error(`Rehearsal service environment file is missing: ${path}.`, { cause: error });
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
if (!details.isFile()) {
|
|
43
|
+
throw new Error(`Rehearsal service environment path is not a file: ${path}.`);
|
|
44
|
+
}
|
|
45
|
+
if ((details.mode & 0o077) !== 0) {
|
|
46
|
+
throw new Error("Rehearsal service environment credentials must be owner-only (chmod 600).");
|
|
47
|
+
}
|
|
48
|
+
const parsed = parseEnv(source);
|
|
49
|
+
const unexpectedKeys = Object.keys(parsed).filter((key) => !keys.includes(key));
|
|
50
|
+
if (unexpectedKeys.length) {
|
|
51
|
+
throw new Error(`Rehearsal service environment contains variables outside its allowlist: ${unexpectedKeys.join(", ")}.`);
|
|
52
|
+
}
|
|
53
|
+
const missingKeys = keys.filter((key) => !parsed[key]?.trim());
|
|
54
|
+
if (missingKeys.length) {
|
|
55
|
+
throw new Error(`Rehearsal service environment is missing required variables: ${missingKeys.join(", ")}.`);
|
|
56
|
+
}
|
|
57
|
+
return Object.fromEntries(keys.map((key) => [key, parsed[key]]));
|
|
58
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Resolve and validate a primary Rehearsal runtime plus any explicitly
|
|
3
|
+
* declared dependent runtimes. Every target remains a complete, independently
|
|
4
|
+
* safe Rehearsal configuration.
|
|
5
|
+
*/
|
|
6
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
7
|
+
import { inspectRehearsalMigrations } from "./plan.mjs";
|
|
8
|
+
import type { RehearsalConfigPathOptions } from "../project/configuration.mjs";
|
|
9
|
+
export type LoadedRehearsalConfig = Awaited<ReturnType<typeof loadRehearsalConfig>>;
|
|
10
|
+
export type DependentDeclaration = LoadedRehearsalConfig["config"]["dependentTargets"][number];
|
|
11
|
+
export interface RuntimeTopologyTarget {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly primary: boolean;
|
|
14
|
+
readonly declaration: DependentDeclaration | null;
|
|
15
|
+
readonly configPath: string;
|
|
16
|
+
readonly config: LoadedRehearsalConfig["config"];
|
|
17
|
+
readonly paths: LoadedRehearsalConfig["paths"];
|
|
18
|
+
}
|
|
19
|
+
export declare const loadRuntimeTopology: (options?: RehearsalConfigPathOptions) => Promise<Readonly<{
|
|
20
|
+
projectRoot: string;
|
|
21
|
+
primary: RuntimeTopologyTarget;
|
|
22
|
+
dependents: readonly RuntimeTopologyTarget[];
|
|
23
|
+
targets: readonly RuntimeTopologyTarget[];
|
|
24
|
+
}>>;
|
|
25
|
+
type MigrationInspection = Awaited<ReturnType<typeof inspectRehearsalMigrations>>;
|
|
26
|
+
declare const candidateSummary: (inspection: MigrationInspection) => Readonly<{
|
|
27
|
+
baselineGenerationId: string;
|
|
28
|
+
candidateSha256: string;
|
|
29
|
+
candidateCount: number;
|
|
30
|
+
candidates: readonly {
|
|
31
|
+
version: string;
|
|
32
|
+
name: string;
|
|
33
|
+
filename: string;
|
|
34
|
+
sha256: string;
|
|
35
|
+
status: string;
|
|
36
|
+
}[];
|
|
37
|
+
}>;
|
|
38
|
+
export declare const inspectRuntimeTopologyCandidates: (options?: RehearsalConfigPathOptions) => Promise<Readonly<{
|
|
39
|
+
topology: Readonly<{
|
|
40
|
+
projectRoot: string;
|
|
41
|
+
primary: RuntimeTopologyTarget;
|
|
42
|
+
dependents: readonly RuntimeTopologyTarget[];
|
|
43
|
+
targets: readonly RuntimeTopologyTarget[];
|
|
44
|
+
}>;
|
|
45
|
+
targets: readonly (RuntimeTopologyTarget & {
|
|
46
|
+
summary: ReturnType<typeof candidateSummary>;
|
|
47
|
+
})[];
|
|
48
|
+
candidateSha256: string;
|
|
49
|
+
candidateCount: number;
|
|
50
|
+
}>>;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Resolve and validate a primary Rehearsal runtime plus any explicitly
|
|
3
|
+
* declared dependent runtimes. Every target remains a complete, independently
|
|
4
|
+
* safe Rehearsal configuration.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
8
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
9
|
+
import { inspectRehearsalMigrations } from "./plan.mjs";
|
|
10
|
+
const isInside = (parent, candidate) => {
|
|
11
|
+
const path = relative(parent, candidate);
|
|
12
|
+
return !path || (!path.startsWith("..") && !isAbsolute(path));
|
|
13
|
+
};
|
|
14
|
+
const assertUnique = (entries, valueFor, label) => {
|
|
15
|
+
const seen = new Map();
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const value = valueFor(entry);
|
|
18
|
+
if (seen.has(value)) {
|
|
19
|
+
throw new Error(`Dependent runtime ${String(entry.name)} conflicts with ${seen.get(value)}: ${label} ${String(value)} must be unique.`);
|
|
20
|
+
}
|
|
21
|
+
seen.set(value, String(entry.name));
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const targetFrom = ({ name, declaration, loaded, primary, }) => Object.freeze({
|
|
25
|
+
name,
|
|
26
|
+
primary,
|
|
27
|
+
declaration,
|
|
28
|
+
configPath: loaded.configPath,
|
|
29
|
+
config: loaded.config,
|
|
30
|
+
paths: loaded.paths,
|
|
31
|
+
});
|
|
32
|
+
export const loadRuntimeTopology = async (options = {}) => {
|
|
33
|
+
const primaryLoaded = await loadRehearsalConfig(options);
|
|
34
|
+
const primary = targetFrom({
|
|
35
|
+
name: "primary",
|
|
36
|
+
declaration: null,
|
|
37
|
+
loaded: primaryLoaded,
|
|
38
|
+
primary: true,
|
|
39
|
+
});
|
|
40
|
+
const dependents = [];
|
|
41
|
+
for (const declaration of primary.config.dependentTargets) {
|
|
42
|
+
const loaded = await loadRehearsalConfig({
|
|
43
|
+
projectRoot: primaryLoaded.projectRoot,
|
|
44
|
+
configPath: declaration.configPath,
|
|
45
|
+
});
|
|
46
|
+
if (loaded.config.dependentTargets.length > 0) {
|
|
47
|
+
throw new Error(`Dependent runtime ${declaration.name} cannot declare its own dependentTargets. Nesting is intentionally unsupported.`);
|
|
48
|
+
}
|
|
49
|
+
dependents.push(targetFrom({
|
|
50
|
+
name: declaration.name,
|
|
51
|
+
declaration,
|
|
52
|
+
loaded,
|
|
53
|
+
primary: false,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
const targets = [primary, ...dependents];
|
|
57
|
+
assertUnique(targets, (target) => target.name, "target name");
|
|
58
|
+
assertUnique(targets, (target) => target.configPath, "config path");
|
|
59
|
+
assertUnique(targets, (target) => target.config.runtime.projectId, "runtime project ID");
|
|
60
|
+
assertUnique(targets.flatMap((target) => Object.entries(target.config.runtime.ports).map(([kind, port]) => ({
|
|
61
|
+
name: `${target.name} (${kind})`,
|
|
62
|
+
port,
|
|
63
|
+
}))), (entry) => entry.port, "runtime port");
|
|
64
|
+
assertUnique(targets, (target) => target.paths.applicationEnvironment, "application environment file");
|
|
65
|
+
assertUnique(targets.filter((target) => target.paths.rehearsalConfig), (target) => target.paths.rehearsalConfig, "Supabase runtime config");
|
|
66
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
67
|
+
for (let otherIndex = index + 1; otherIndex < targets.length; otherIndex += 1) {
|
|
68
|
+
const left = targets[index];
|
|
69
|
+
const right = targets[otherIndex];
|
|
70
|
+
if (isInside(left.paths.artifactDirectory, right.paths.artifactDirectory) ||
|
|
71
|
+
isInside(right.paths.artifactDirectory, left.paths.artifactDirectory)) {
|
|
72
|
+
throw new Error(`Dependent runtimes ${left.name} and ${right.name} must use separate, non-nested .rehearsal artifact directories.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return Object.freeze({
|
|
77
|
+
projectRoot: primaryLoaded.projectRoot,
|
|
78
|
+
primary,
|
|
79
|
+
dependents: Object.freeze(dependents),
|
|
80
|
+
targets: Object.freeze(targets),
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
const candidateSummary = (inspection) => {
|
|
84
|
+
const candidates = inspection.migrations.filter((migration) => migration.status === "candidate");
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
baselineGenerationId: inspection.baselineGenerationId,
|
|
87
|
+
candidateSha256: inspection.candidateSha256,
|
|
88
|
+
candidateCount: candidates.length,
|
|
89
|
+
candidates: Object.freeze(candidates),
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
export const inspectRuntimeTopologyCandidates = async (options = {}) => {
|
|
93
|
+
const topology = await loadRuntimeTopology(options);
|
|
94
|
+
const targets = [];
|
|
95
|
+
for (const target of topology.targets) {
|
|
96
|
+
targets.push(Object.freeze({
|
|
97
|
+
...target,
|
|
98
|
+
summary: candidateSummary(await inspectRehearsalMigrations({
|
|
99
|
+
projectRoot: topology.projectRoot,
|
|
100
|
+
configPath: target.configPath,
|
|
101
|
+
})),
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
if (targets.length === 1) {
|
|
105
|
+
return Object.freeze({
|
|
106
|
+
topology,
|
|
107
|
+
targets: Object.freeze(targets),
|
|
108
|
+
candidateSha256: targets[0].summary.candidateSha256,
|
|
109
|
+
candidateCount: targets[0].summary.candidateCount,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const digestInput = targets.map((target) => ({
|
|
113
|
+
name: target.name,
|
|
114
|
+
candidateSha256: target.summary.candidateSha256,
|
|
115
|
+
candidates: target.summary.candidates.map((candidate) => ({
|
|
116
|
+
filename: candidate.filename,
|
|
117
|
+
sha256: candidate.sha256,
|
|
118
|
+
})),
|
|
119
|
+
}));
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
topology,
|
|
122
|
+
targets: Object.freeze(targets),
|
|
123
|
+
candidateSha256: createHash("sha256")
|
|
124
|
+
.update(JSON.stringify(digestInput))
|
|
125
|
+
.digest("hex"),
|
|
126
|
+
candidateCount: targets.reduce((total, target) => total + target.summary.candidateCount, 0),
|
|
127
|
+
});
|
|
128
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Provide stable Rehearsal result, failure, redaction, and rendering
|
|
3
|
+
* contracts for humans and automation. Do not run directly; this module is reusable
|
|
4
|
+
* script infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
export declare const REHEARSAL_RESULT_VERSION = 1;
|
|
7
|
+
export declare const REHEARSAL_VERSION: string;
|
|
8
|
+
export declare const REHEARSAL_EXIT_CODES: Readonly<{
|
|
9
|
+
success: 0;
|
|
10
|
+
configuration_invalid: 2;
|
|
11
|
+
unsafe_environment: 3;
|
|
12
|
+
baseline_invalid: 4;
|
|
13
|
+
baseline_checksum_mismatch: 5;
|
|
14
|
+
migration_candidate_failure: 6;
|
|
15
|
+
migration_verification_failure: 7;
|
|
16
|
+
application_proof_failure: 8;
|
|
17
|
+
runtime_dependency_failure: 9;
|
|
18
|
+
internal_failure: 10;
|
|
19
|
+
}>;
|
|
20
|
+
export type RehearsalErrorCategory = keyof typeof REHEARSAL_EXIT_CODES;
|
|
21
|
+
export type RehearsalStatus = "success" | "not_ready" | "error";
|
|
22
|
+
export interface RehearsalErrorOptions {
|
|
23
|
+
category: RehearsalErrorCategory;
|
|
24
|
+
code: string;
|
|
25
|
+
message: string;
|
|
26
|
+
expected?: unknown;
|
|
27
|
+
actual?: unknown;
|
|
28
|
+
context?: unknown;
|
|
29
|
+
refused?: string;
|
|
30
|
+
suggestions?: readonly string[];
|
|
31
|
+
cause?: unknown;
|
|
32
|
+
}
|
|
33
|
+
export interface RehearsalErrorOverrides {
|
|
34
|
+
category?: RehearsalErrorCategory;
|
|
35
|
+
code?: string;
|
|
36
|
+
expected?: unknown;
|
|
37
|
+
actual?: unknown;
|
|
38
|
+
context?: unknown;
|
|
39
|
+
refused?: string;
|
|
40
|
+
suggestions?: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
export interface RehearsalResultOptions<Data = unknown> {
|
|
43
|
+
command: string;
|
|
44
|
+
status: RehearsalStatus;
|
|
45
|
+
data: Data;
|
|
46
|
+
warnings?: readonly string[];
|
|
47
|
+
startedAt?: Date;
|
|
48
|
+
durationMs?: number;
|
|
49
|
+
}
|
|
50
|
+
export interface RehearsalResult<Data = unknown> {
|
|
51
|
+
schemaVersion: number;
|
|
52
|
+
rehearsalVersion: string;
|
|
53
|
+
command: string;
|
|
54
|
+
status: RehearsalStatus;
|
|
55
|
+
startedAt: string;
|
|
56
|
+
durationMs: number;
|
|
57
|
+
warnings: readonly string[];
|
|
58
|
+
data: Data;
|
|
59
|
+
}
|
|
60
|
+
export interface SerializedRehearsalError {
|
|
61
|
+
schemaVersion: number;
|
|
62
|
+
rehearsalVersion: string;
|
|
63
|
+
status: "error";
|
|
64
|
+
error: {
|
|
65
|
+
category: RehearsalErrorCategory;
|
|
66
|
+
code: string;
|
|
67
|
+
message: string;
|
|
68
|
+
expected: unknown;
|
|
69
|
+
actual: unknown;
|
|
70
|
+
context: unknown;
|
|
71
|
+
refused: string | undefined;
|
|
72
|
+
suggestions: readonly string[];
|
|
73
|
+
diagnosticId: string;
|
|
74
|
+
cause?: string;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export declare const redactDiagnosticValue: (value: unknown, key?: string) => unknown;
|
|
78
|
+
export declare class RehearsalError extends Error {
|
|
79
|
+
readonly category: RehearsalErrorCategory;
|
|
80
|
+
readonly code: string;
|
|
81
|
+
readonly expected: unknown;
|
|
82
|
+
readonly actual: unknown;
|
|
83
|
+
readonly context: unknown;
|
|
84
|
+
readonly refused: string | undefined;
|
|
85
|
+
readonly suggestions: readonly string[];
|
|
86
|
+
readonly diagnosticId: string;
|
|
87
|
+
readonly exitCode: number;
|
|
88
|
+
constructor({ category, code, message, expected, actual, context, refused, suggestions, cause, }: RehearsalErrorOptions);
|
|
89
|
+
}
|
|
90
|
+
export declare const normalizeRehearsalError: (error: unknown, overrides?: RehearsalErrorOverrides) => RehearsalError;
|
|
91
|
+
export declare const createRehearsalResult: ({ command, status, data, warnings, startedAt, durationMs, }: RehearsalResultOptions) => RehearsalResult;
|
|
92
|
+
export declare const serializeRehearsalError: (error: unknown, { debug }?: {
|
|
93
|
+
debug?: boolean;
|
|
94
|
+
}) => SerializedRehearsalError;
|
|
95
|
+
export declare const renderHumanError: (error: unknown, { debug }?: {
|
|
96
|
+
debug?: boolean;
|
|
97
|
+
}) => string;
|