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