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