@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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** Stream only explicitly approved Supabase Storage objects into a baseline. */
|
|
2
|
+
|
|
3
|
+
const BUCKET = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
|
|
4
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
5
|
+
|
|
6
|
+
const safeBaseUrl = (value) => {
|
|
7
|
+
let url;
|
|
8
|
+
try {
|
|
9
|
+
url = new URL(value);
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error("Asset reader URL is invalid.");
|
|
12
|
+
}
|
|
13
|
+
if (
|
|
14
|
+
url.protocol !== "https:" &&
|
|
15
|
+
!(url.protocol === "http:" && LOOPBACK.has(url.hostname))
|
|
16
|
+
) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
"Asset reader URL must use HTTPS or a loopback HTTP endpoint.",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
url.username = "";
|
|
22
|
+
url.password = "";
|
|
23
|
+
url.search = "";
|
|
24
|
+
url.hash = "";
|
|
25
|
+
return url.toString().replace(/\/$/u, "");
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const safePath = (value, label) => {
|
|
29
|
+
if (
|
|
30
|
+
typeof value !== "string" ||
|
|
31
|
+
!value ||
|
|
32
|
+
value.startsWith("/") ||
|
|
33
|
+
value.split("/").some((part) => !part || part === "." || part === "..")
|
|
34
|
+
) {
|
|
35
|
+
throw new Error(`${label} is unsafe.`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const readError = (label, response) =>
|
|
41
|
+
new Error(
|
|
42
|
+
`${label} failed with HTTP ${response.status}; response content was withheld.`,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const responseBody = async function* ({
|
|
46
|
+
response,
|
|
47
|
+
expectedBytes,
|
|
48
|
+
maximumBytes,
|
|
49
|
+
}) {
|
|
50
|
+
if (!response.body) throw new Error("Storage object response has no body.");
|
|
51
|
+
let bytes = 0;
|
|
52
|
+
for await (const chunk of response.body) {
|
|
53
|
+
const buffer = Buffer.from(chunk);
|
|
54
|
+
bytes += buffer.length;
|
|
55
|
+
if (bytes > maximumBytes)
|
|
56
|
+
throw new Error("Storage object exceeded its reviewed size limit.");
|
|
57
|
+
yield buffer;
|
|
58
|
+
}
|
|
59
|
+
if (expectedBytes !== null && bytes !== expectedBytes) {
|
|
60
|
+
throw new Error("Storage object size changed during transfer.");
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const streamApprovedSupabaseAssets = async function* ({
|
|
65
|
+
baseUrl,
|
|
66
|
+
token,
|
|
67
|
+
declarations,
|
|
68
|
+
fetchImplementation = fetch,
|
|
69
|
+
maximumObjects = 10_000,
|
|
70
|
+
maximumObjectBytes = 50 * 1024 * 1024,
|
|
71
|
+
maximumTotalBytes = 2 * 1024 * 1024 * 1024,
|
|
72
|
+
}) {
|
|
73
|
+
const endpoint = safeBaseUrl(baseUrl);
|
|
74
|
+
if (typeof token !== "string" || token.length < 16) {
|
|
75
|
+
throw new Error("A scoped Storage reader token is required.");
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(declarations) || declarations.length === 0) return;
|
|
78
|
+
let objectCount = 0;
|
|
79
|
+
let totalBytes = 0;
|
|
80
|
+
for (const declaration of declarations) {
|
|
81
|
+
if (!BUCKET.test(declaration.bucket ?? "")) {
|
|
82
|
+
throw new Error("Approved Storage bucket is invalid.");
|
|
83
|
+
}
|
|
84
|
+
const prefix = safePath(
|
|
85
|
+
declaration.prefix.replace(/\/$/u, ""),
|
|
86
|
+
"Approved Storage prefix",
|
|
87
|
+
);
|
|
88
|
+
let offset = 0;
|
|
89
|
+
while (true) {
|
|
90
|
+
const response = await fetchImplementation(
|
|
91
|
+
`${endpoint}/storage/v1/object/list/${encodeURIComponent(declaration.bucket)}`,
|
|
92
|
+
{
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
authorization: `Bearer ${token}`,
|
|
96
|
+
"content-type": "application/json",
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
prefix,
|
|
100
|
+
limit: 100,
|
|
101
|
+
offset,
|
|
102
|
+
sortBy: { column: "name", order: "asc" },
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
if (!response.ok) throw readError("Storage inventory", response);
|
|
107
|
+
const inventory = await response.json();
|
|
108
|
+
if (!Array.isArray(inventory))
|
|
109
|
+
throw new Error("Storage inventory response is invalid.");
|
|
110
|
+
for (const object of inventory) {
|
|
111
|
+
const name = safePath(
|
|
112
|
+
`${prefix}/${object.name}`,
|
|
113
|
+
"Storage object path",
|
|
114
|
+
);
|
|
115
|
+
if (!name.startsWith(`${prefix}/`))
|
|
116
|
+
throw new Error("Storage inventory escaped its approved prefix.");
|
|
117
|
+
const expectedBytes = Number(
|
|
118
|
+
object.metadata?.size ?? object.metadata?.contentLength,
|
|
119
|
+
);
|
|
120
|
+
if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0) {
|
|
121
|
+
throw new Error("Storage inventory omitted an exact object size.");
|
|
122
|
+
}
|
|
123
|
+
if (expectedBytes > maximumObjectBytes) {
|
|
124
|
+
throw new Error("Storage object exceeds its reviewed size limit.");
|
|
125
|
+
}
|
|
126
|
+
objectCount += 1;
|
|
127
|
+
totalBytes += expectedBytes;
|
|
128
|
+
if (objectCount > maximumObjects || totalBytes > maximumTotalBytes) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
"Storage inventory exceeds its reviewed transfer boundary.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const encodedPath = name.split("/").map(encodeURIComponent).join("/");
|
|
134
|
+
const download = await fetchImplementation(
|
|
135
|
+
`${endpoint}/storage/v1/object/authenticated/${encodeURIComponent(declaration.bucket)}/${encodedPath}`,
|
|
136
|
+
{
|
|
137
|
+
headers: {
|
|
138
|
+
authorization: `Bearer ${token}`,
|
|
139
|
+
...(object.metadata?.eTag
|
|
140
|
+
? { "if-match": object.metadata.eTag }
|
|
141
|
+
: {}),
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
if (!download.ok) throw readError("Storage object transfer", download);
|
|
146
|
+
const responseEtag = download.headers.get("etag");
|
|
147
|
+
if (object.metadata?.eTag) {
|
|
148
|
+
if (!responseEtag) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"Storage transfer omitted the reviewed object version.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (responseEtag !== object.metadata.eTag) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"Storage object changed between inventory and transfer.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
yield {
|
|
160
|
+
bucket: declaration.bucket,
|
|
161
|
+
objectPath: name,
|
|
162
|
+
contentType:
|
|
163
|
+
download.headers.get("content-type") ??
|
|
164
|
+
object.metadata?.mimetype ??
|
|
165
|
+
"application/octet-stream",
|
|
166
|
+
content: responseBody({
|
|
167
|
+
response: download,
|
|
168
|
+
expectedBytes,
|
|
169
|
+
maximumBytes: maximumObjectBytes,
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (inventory.length < 100) break;
|
|
174
|
+
offset += inventory.length;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream an approved PostgreSQL export surface into a staged, verified baseline
|
|
3
|
+
* using one coherent read-only snapshot. No raw row values are logged.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { mkdir, statfs } from "node:fs/promises";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import pg from "pg";
|
|
11
|
+
import {
|
|
12
|
+
activateBaselineGeneration,
|
|
13
|
+
createAndActivateBaseline,
|
|
14
|
+
createBaselineGenerationId,
|
|
15
|
+
removeStagedBaselineGeneration,
|
|
16
|
+
} from "../baseline/artifact.mjs";
|
|
17
|
+
import {
|
|
18
|
+
buildMigrationLedgerInventory,
|
|
19
|
+
createMigrationReplayReceipt,
|
|
20
|
+
readMigrationSourceBundle,
|
|
21
|
+
} from "../runtime/migration_history.mjs";
|
|
22
|
+
import {
|
|
23
|
+
createPrivacyEngine,
|
|
24
|
+
validateExecutablePrivacyPolicy,
|
|
25
|
+
} from "../baseline/privacy_engine.mjs";
|
|
26
|
+
import {
|
|
27
|
+
sourceTargetFingerprint,
|
|
28
|
+
validateSourceAccessPolicy,
|
|
29
|
+
} from "./access.mjs";
|
|
30
|
+
|
|
31
|
+
const { Client } = pg;
|
|
32
|
+
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
33
|
+
const DEFAULT_LIMITS = Object.freeze({
|
|
34
|
+
batchRows: 500,
|
|
35
|
+
maximumRows: 1_000_000,
|
|
36
|
+
maximumBytes: 2 * 1024 * 1024 * 1024,
|
|
37
|
+
diskHeadroomBytes: 64 * 1024 * 1024,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const quoteIdentifier = (value) => {
|
|
41
|
+
if (!IDENTIFIER.test(value ?? ""))
|
|
42
|
+
throw new Error("Unsafe source extraction identifier.");
|
|
43
|
+
return `"${value}"`;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
|
|
47
|
+
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
48
|
+
|
|
49
|
+
const normalizeLimits = (limits = {}) => {
|
|
50
|
+
const normalized = { ...DEFAULT_LIMITS, ...limits };
|
|
51
|
+
for (const [name, value] of Object.entries(normalized)) {
|
|
52
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Source extraction limit ${name} must be a positive integer.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (normalized.batchRows > 10_000) {
|
|
59
|
+
throw new Error("Source extraction batchRows may not exceed 10000.");
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze(normalized);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const connect = async (connectionString, clientFactory) => {
|
|
65
|
+
const client = clientFactory
|
|
66
|
+
? await clientFactory(connectionString)
|
|
67
|
+
: new Client({
|
|
68
|
+
connectionString,
|
|
69
|
+
application_name: "rehearsal-baseline-refresh",
|
|
70
|
+
});
|
|
71
|
+
await client.connect?.();
|
|
72
|
+
return client;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const readLedger = async (client, declaration) => {
|
|
76
|
+
const result = await client.query(
|
|
77
|
+
`select
|
|
78
|
+
${quoteIdentifier(declaration.versionColumn)} as version,
|
|
79
|
+
${quoteIdentifier(declaration.nameColumn)} as name,
|
|
80
|
+
${quoteIdentifier(declaration.statementsColumn)} as statements
|
|
81
|
+
from ${quoteIdentifier(declaration.schema)}.${quoteIdentifier(declaration.table)}
|
|
82
|
+
order by ${quoteIdentifier(declaration.versionColumn)}`,
|
|
83
|
+
);
|
|
84
|
+
return buildMigrationLedgerInventory(
|
|
85
|
+
result.rows,
|
|
86
|
+
"Approved source migration ledger",
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const inspectExportSchema = async (client, policy) => {
|
|
91
|
+
const tables = [];
|
|
92
|
+
for (const relation of policy.relations) {
|
|
93
|
+
const result = await client.query(
|
|
94
|
+
`select column_name as name, data_type, udt_name, is_nullable,
|
|
95
|
+
is_generated, is_identity, ordinal_position
|
|
96
|
+
from information_schema.columns
|
|
97
|
+
where table_schema = $1 and table_name = $2
|
|
98
|
+
order by ordinal_position`,
|
|
99
|
+
[policy.exportSchema, relation.view],
|
|
100
|
+
);
|
|
101
|
+
const actualNames = result.rows.map((row) => row.name);
|
|
102
|
+
if (actualNames.join("\0") !== relation.columns.join("\0")) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Approved export view ${relation.view} no longer has the reviewed columns.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
tables.push({
|
|
108
|
+
schema: relation.targetSchema,
|
|
109
|
+
name: relation.targetTable,
|
|
110
|
+
sourceView: relation.view,
|
|
111
|
+
columns: result.rows.map((row) => ({
|
|
112
|
+
name: row.name,
|
|
113
|
+
dataType: row.data_type,
|
|
114
|
+
underlyingType: row.udt_name,
|
|
115
|
+
nullable: row.is_nullable === "YES",
|
|
116
|
+
generated: row.is_generated,
|
|
117
|
+
identity: row.is_identity,
|
|
118
|
+
ordinal: Number(row.ordinal_position),
|
|
119
|
+
})),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return Object.freeze(tables);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const estimateExport = async (client, policy) => {
|
|
126
|
+
const tables = [];
|
|
127
|
+
let rows = 0;
|
|
128
|
+
let bytes = 0;
|
|
129
|
+
for (const relation of policy.relations) {
|
|
130
|
+
const result = await client.query(
|
|
131
|
+
`select count(*)::bigint as rows,
|
|
132
|
+
coalesce(sum(pg_column_size(export_row)), 0)::bigint as bytes
|
|
133
|
+
from ${quoteIdentifier(policy.exportSchema)}.${quoteIdentifier(relation.view)} export_row`,
|
|
134
|
+
);
|
|
135
|
+
const tableRows = Number(result.rows[0].rows);
|
|
136
|
+
const tableBytes = Number(result.rows[0].bytes);
|
|
137
|
+
if (!Number.isSafeInteger(tableRows) || !Number.isSafeInteger(tableBytes)) {
|
|
138
|
+
throw new Error("Source export estimate exceeds safe numeric limits.");
|
|
139
|
+
}
|
|
140
|
+
rows += tableRows;
|
|
141
|
+
bytes += tableBytes;
|
|
142
|
+
tables.push({
|
|
143
|
+
schema: relation.targetSchema,
|
|
144
|
+
name: relation.targetTable,
|
|
145
|
+
rows: tableRows,
|
|
146
|
+
bytes: tableBytes,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return Object.freeze({ rows, bytes, tables: Object.freeze(tables) });
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const assertCapacity = async ({
|
|
153
|
+
artifactRoot,
|
|
154
|
+
estimate,
|
|
155
|
+
limits,
|
|
156
|
+
availableBytes,
|
|
157
|
+
}) => {
|
|
158
|
+
if (estimate.rows > limits.maximumRows) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Approved source contains ${estimate.rows} rows; the configured limit is ${limits.maximumRows}.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (estimate.bytes > limits.maximumBytes) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
"Approved source estimate exceeds the configured byte limit.",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
await mkdir(dirname(artifactRoot), { recursive: true, mode: 0o700 });
|
|
169
|
+
if (!Number.isSafeInteger(availableBytes) || availableBytes < 0) {
|
|
170
|
+
throw new Error("Available baseline disk capacity could not be measured.");
|
|
171
|
+
}
|
|
172
|
+
const available = availableBytes;
|
|
173
|
+
const required = estimate.bytes * 2 + limits.diskHeadroomBytes;
|
|
174
|
+
if (available < required) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Baseline refresh needs approximately ${required} free bytes but only ${available} are available.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const actualAvailableBytes = async (artifactRoot) => {
|
|
182
|
+
const stats = await statfs(dirname(artifactRoot));
|
|
183
|
+
return Number(stats.bavail) * Number(stats.bsize);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const assertPolicyCoverage = (privacyPolicy, sourceSchema) => {
|
|
187
|
+
const selected = new Map(
|
|
188
|
+
sourceSchema.map((table) => [
|
|
189
|
+
`${table.schema}.${table.name}`,
|
|
190
|
+
table.columns.map((column) => column.name),
|
|
191
|
+
]),
|
|
192
|
+
);
|
|
193
|
+
const classified = new Map(
|
|
194
|
+
privacyPolicy.tables.map((table) => [
|
|
195
|
+
`${table.schema ?? "public"}.${table.name}`,
|
|
196
|
+
table.columns.map((column) => column.name),
|
|
197
|
+
]),
|
|
198
|
+
);
|
|
199
|
+
if (selected.size !== classified.size) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
"Privacy policy table coverage does not match the approved source scope.",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
for (const [table, columns] of selected) {
|
|
205
|
+
const decisions = classified.get(table);
|
|
206
|
+
if (
|
|
207
|
+
!decisions ||
|
|
208
|
+
columns.slice().sort().join("\0") !== decisions.slice().sort().join("\0")
|
|
209
|
+
) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Privacy policy coverage changed for source table ${table}.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const streamSanitizedRecords = async function* ({
|
|
218
|
+
client,
|
|
219
|
+
sourcePolicy,
|
|
220
|
+
privacyEngine,
|
|
221
|
+
limits,
|
|
222
|
+
progress,
|
|
223
|
+
}) {
|
|
224
|
+
let totalRows = 0;
|
|
225
|
+
let totalBytes = 0;
|
|
226
|
+
for (const [index, relation] of sourcePolicy.relations.entries()) {
|
|
227
|
+
const cursor = `rehearsal_export_${index}`;
|
|
228
|
+
const columns = relation.columns.map(quoteIdentifier).join(", ");
|
|
229
|
+
const orderBy = relation.orderBy.map(quoteIdentifier).join(", ");
|
|
230
|
+
await client.query(
|
|
231
|
+
`declare ${quoteIdentifier(cursor)} no scroll cursor for
|
|
232
|
+
select ${columns}
|
|
233
|
+
from ${quoteIdentifier(sourcePolicy.exportSchema)}.${quoteIdentifier(relation.view)}
|
|
234
|
+
order by ${orderBy}`,
|
|
235
|
+
);
|
|
236
|
+
try {
|
|
237
|
+
while (true) {
|
|
238
|
+
const batch = await client.query(
|
|
239
|
+
`fetch forward ${limits.batchRows} from ${quoteIdentifier(cursor)}`,
|
|
240
|
+
);
|
|
241
|
+
if (batch.rows.length === 0) break;
|
|
242
|
+
for (const row of batch.rows) {
|
|
243
|
+
const sanitized = privacyEngine.sanitize({
|
|
244
|
+
schema: relation.targetSchema,
|
|
245
|
+
table: relation.targetTable,
|
|
246
|
+
row,
|
|
247
|
+
});
|
|
248
|
+
if (!sanitized) continue;
|
|
249
|
+
totalRows += 1;
|
|
250
|
+
totalBytes += Buffer.byteLength(JSON.stringify(sanitized));
|
|
251
|
+
if (
|
|
252
|
+
totalRows > limits.maximumRows ||
|
|
253
|
+
totalBytes > limits.maximumBytes
|
|
254
|
+
) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
"Source stream exceeded its reviewed extraction boundary.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
yield sanitized;
|
|
260
|
+
}
|
|
261
|
+
progress?.({
|
|
262
|
+
table: relation.targetTable,
|
|
263
|
+
rows: totalRows,
|
|
264
|
+
bytes: totalBytes,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
} finally {
|
|
268
|
+
await client
|
|
269
|
+
.query(`close ${quoteIdentifier(cursor)}`)
|
|
270
|
+
.catch(() => undefined);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export const refreshPostgresqlBaseline = async ({
|
|
276
|
+
connectionString,
|
|
277
|
+
sourcePolicy: sourcePolicyInput,
|
|
278
|
+
sourceAccessReceipt,
|
|
279
|
+
privacyPolicyBytes,
|
|
280
|
+
privacyKey,
|
|
281
|
+
artifactRoot,
|
|
282
|
+
migrationDirectory,
|
|
283
|
+
limits: limitInput,
|
|
284
|
+
availableBytes,
|
|
285
|
+
clientFactory,
|
|
286
|
+
progress,
|
|
287
|
+
assets = [],
|
|
288
|
+
}) => {
|
|
289
|
+
const sourcePolicy = validateSourceAccessPolicy(sourcePolicyInput);
|
|
290
|
+
if (
|
|
291
|
+
sourceTargetFingerprint(connectionString) !== sourcePolicy.targetFingerprint
|
|
292
|
+
) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
"Source reader credential does not match the reviewed target.",
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const expectedReceiptShape = {
|
|
298
|
+
exportSchema: sourcePolicy.exportSchema,
|
|
299
|
+
views: sourcePolicy.relations.map((relation) => relation.view).sort(),
|
|
300
|
+
relations: sourcePolicy.relations.map((relation) => ({
|
|
301
|
+
...relation.source,
|
|
302
|
+
columns: relation.columns,
|
|
303
|
+
})),
|
|
304
|
+
migrationLedger: sourcePolicy.migrationLedger,
|
|
305
|
+
readerRole: sourcePolicy.reader.role,
|
|
306
|
+
ownerRole: sourcePolicy.reader.ownerRole,
|
|
307
|
+
};
|
|
308
|
+
const actualReceiptShape = {
|
|
309
|
+
exportSchema: sourceAccessReceipt?.exportSchema,
|
|
310
|
+
views: [...(sourceAccessReceipt?.views ?? [])].sort(),
|
|
311
|
+
relations: sourceAccessReceipt?.relations,
|
|
312
|
+
migrationLedger: sourceAccessReceipt?.migrationLedger,
|
|
313
|
+
readerRole: sourceAccessReceipt?.readerRole,
|
|
314
|
+
ownerRole: sourceAccessReceipt?.ownerRole,
|
|
315
|
+
};
|
|
316
|
+
if (
|
|
317
|
+
sourceAccessReceipt?.targetFingerprint !== sourcePolicy.targetFingerprint ||
|
|
318
|
+
!/^[a-f0-9]{64}$/u.test(sourceAccessReceipt?.planDigest ?? "") ||
|
|
319
|
+
new Date(sourceAccessReceipt.expiresAt).valueOf() <= Date.now() ||
|
|
320
|
+
JSON.stringify(actualReceiptShape) !== JSON.stringify(expectedReceiptShape)
|
|
321
|
+
) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
"Source reader receipt is missing, mismatched, or expired.",
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
const privacyPolicy = validateExecutablePrivacyPolicy(
|
|
327
|
+
JSON.parse(Buffer.from(privacyPolicyBytes).toString("utf8")),
|
|
328
|
+
);
|
|
329
|
+
const privacyEngine = createPrivacyEngine({
|
|
330
|
+
policy: privacyPolicy,
|
|
331
|
+
key: privacyKey,
|
|
332
|
+
});
|
|
333
|
+
const limits = normalizeLimits(limitInput);
|
|
334
|
+
const client = await connect(connectionString, clientFactory);
|
|
335
|
+
let generationId;
|
|
336
|
+
let transactionOpen = false;
|
|
337
|
+
try {
|
|
338
|
+
await client.query(
|
|
339
|
+
"begin transaction isolation level repeatable read read only",
|
|
340
|
+
);
|
|
341
|
+
transactionOpen = true;
|
|
342
|
+
const [sourceLedger, sourceSchema] = await Promise.all([
|
|
343
|
+
readLedger(client, sourcePolicy.migrationLedger),
|
|
344
|
+
inspectExportSchema(client, sourcePolicy),
|
|
345
|
+
]);
|
|
346
|
+
assertPolicyCoverage(privacyPolicy, sourceSchema);
|
|
347
|
+
if (privacyPolicy.migrationCutoff !== sourceLedger.at(-1).version) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
"Privacy policy cutoff does not match the coherent source ledger.",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const migrationFiles = await readMigrationSourceBundle({
|
|
353
|
+
directory: new URL("./", pathToFileURL(`${migrationDirectory}/`)),
|
|
354
|
+
sourceLedger,
|
|
355
|
+
});
|
|
356
|
+
const migrationReceipt = createMigrationReplayReceipt({
|
|
357
|
+
files: migrationFiles,
|
|
358
|
+
ledger: sourceLedger,
|
|
359
|
+
});
|
|
360
|
+
const estimate = await estimateExport(client, sourcePolicy);
|
|
361
|
+
await assertCapacity({
|
|
362
|
+
artifactRoot,
|
|
363
|
+
estimate,
|
|
364
|
+
limits,
|
|
365
|
+
availableBytes:
|
|
366
|
+
availableBytes ?? (await actualAvailableBytes(artifactRoot)),
|
|
367
|
+
});
|
|
368
|
+
generationId = createBaselineGenerationId();
|
|
369
|
+
const schemaSha256 = sha256(canonicalJson(sourceSchema));
|
|
370
|
+
const policySha256 = sha256(privacyPolicyBytes);
|
|
371
|
+
const manifest = await createAndActivateBaseline({
|
|
372
|
+
artifactRoot,
|
|
373
|
+
generationId,
|
|
374
|
+
activate: false,
|
|
375
|
+
records: streamSanitizedRecords({
|
|
376
|
+
client,
|
|
377
|
+
sourcePolicy,
|
|
378
|
+
privacyEngine,
|
|
379
|
+
limits,
|
|
380
|
+
progress,
|
|
381
|
+
}),
|
|
382
|
+
metadata: {
|
|
383
|
+
migrationCutoff: sourceLedger.at(-1).version,
|
|
384
|
+
migrationHistorySha256: migrationReceipt.historySha256,
|
|
385
|
+
sanitizationPolicySha256: policySha256,
|
|
386
|
+
sourceMigrationHistory: sourceLedger,
|
|
387
|
+
sourceReceiptSha256: sha256(
|
|
388
|
+
canonicalJson({
|
|
389
|
+
targetFingerprint: sourceAccessReceipt.targetFingerprint,
|
|
390
|
+
planDigest: sourceAccessReceipt.planDigest,
|
|
391
|
+
expiresAt: sourceAccessReceipt.expiresAt,
|
|
392
|
+
}),
|
|
393
|
+
),
|
|
394
|
+
sourceSchemaSha256: schemaSha256,
|
|
395
|
+
privacyKeyFingerprint: privacyEngine.keyFingerprint,
|
|
396
|
+
extraction: {
|
|
397
|
+
rowsEstimated: estimate.rows,
|
|
398
|
+
bytesEstimated: estimate.bytes,
|
|
399
|
+
batchRows: limits.batchRows,
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
expectedTables: privacyPolicy.tables
|
|
403
|
+
.filter((table) => table.sourceRows !== "EXCLUDE")
|
|
404
|
+
.map((table) =>
|
|
405
|
+
(table.schema ?? "public") === "public"
|
|
406
|
+
? table.name
|
|
407
|
+
: `${table.schema}.${table.name}`,
|
|
408
|
+
),
|
|
409
|
+
migrationFiles,
|
|
410
|
+
assets,
|
|
411
|
+
});
|
|
412
|
+
const observer = await connect(connectionString, clientFactory);
|
|
413
|
+
try {
|
|
414
|
+
const [latestLedger, latestSchema] = await Promise.all([
|
|
415
|
+
readLedger(observer, sourcePolicy.migrationLedger),
|
|
416
|
+
inspectExportSchema(observer, sourcePolicy),
|
|
417
|
+
]);
|
|
418
|
+
if (
|
|
419
|
+
sha256(canonicalJson(latestLedger)) !==
|
|
420
|
+
sha256(canonicalJson(sourceLedger)) ||
|
|
421
|
+
sha256(canonicalJson(latestSchema)) !== schemaSha256
|
|
422
|
+
) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
"Source schema or migration ledger changed during baseline preparation.",
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
} finally {
|
|
428
|
+
await observer.end?.();
|
|
429
|
+
}
|
|
430
|
+
await client.query("commit");
|
|
431
|
+
transactionOpen = false;
|
|
432
|
+
await activateBaselineGeneration({ artifactRoot, generationId });
|
|
433
|
+
return { generationId, manifest, estimate };
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (transactionOpen) await client.query("rollback").catch(() => undefined);
|
|
436
|
+
if (generationId) {
|
|
437
|
+
await removeStagedBaselineGeneration({
|
|
438
|
+
artifactRoot,
|
|
439
|
+
generationId,
|
|
440
|
+
}).catch(() => undefined);
|
|
441
|
+
}
|
|
442
|
+
throw error;
|
|
443
|
+
} finally {
|
|
444
|
+
await client.end?.();
|
|
445
|
+
}
|
|
446
|
+
};
|