@rehearsal-db/core 0.1.0-beta.4 → 0.1.0-beta.6

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.
@@ -105,6 +105,14 @@ const resultCheck = async ({ id, label, run, remediation }) => {
105
105
  };
106
106
 
107
107
  const assertRuntimeConfigMatches = async ({ config, paths }) => {
108
+ if (config.runtime.target === "postgresql") {
109
+ if (paths.rehearsalConfig !== null || paths.supabaseWorkdir !== null) {
110
+ throw new Error(
111
+ "The PostgreSQL target unexpectedly resolved Supabase paths.",
112
+ );
113
+ }
114
+ return `isolated PostgreSQL image ${config.postgresql.image} and a loopback database port are configured`;
115
+ }
108
116
  const source = await readFile(paths.rehearsalConfig, "utf8");
109
117
  const expected = [
110
118
  ["project_id", config.runtime.projectId],
@@ -217,7 +225,8 @@ export const buildRehearsalPlan = async (options = {}) => {
217
225
  project: config.project.name,
218
226
  },
219
227
  environment: {
220
- kind: "isolated_local_supabase",
228
+ target: config.runtime.target,
229
+ kind: `isolated_local_${config.runtime.target}`,
221
230
  applicationUrl: config.runtime.applicationUrl,
222
231
  projectId: config.runtime.projectId,
223
232
  ports: config.runtime.ports,
@@ -226,7 +235,7 @@ export const buildRehearsalPlan = async (options = {}) => {
226
235
  authenticationProviders: config.safety.authenticationProviders,
227
236
  barriers: [
228
237
  "versioned configuration accepts loopback hosts only",
229
- "runtime uses a dedicated local Supabase workdir and project id",
238
+ `runtime uses a dedicated local ${config.runtime.target === "supabase" ? "Supabase workdir" : "PostgreSQL container and volume"} and project id`,
230
239
  "child processes receive an allowlisted environment",
231
240
  "application egress policy denies hosted targets and side effects",
232
241
  ...(config.safety.authenticationProviders.length
@@ -382,14 +391,37 @@ export const runRehearsalDoctor = async (options = {}) => {
382
391
  remediation: "Use npm for the initial Rehearsal contract.",
383
392
  },
384
393
  {
385
- id: "supabase-cli",
386
- label: "Supabase CLI",
394
+ id:
395
+ config.runtime.target === "supabase"
396
+ ? "supabase-cli"
397
+ : "postgresql-image",
398
+ label:
399
+ config.runtime.target === "supabase"
400
+ ? "Supabase CLI"
401
+ : "PostgreSQL image",
387
402
  run: async () => {
388
- const result = commandAvailable("supabase");
389
- if (!result.available) throw new Error("Supabase CLI is unavailable.");
390
- return result.version;
403
+ if (config.runtime.target === "supabase") {
404
+ const result = commandAvailable("supabase");
405
+ if (!result.available)
406
+ throw new Error("Supabase CLI is unavailable.");
407
+ return result.version;
408
+ }
409
+ const result = commandAvailable("docker", [
410
+ "image",
411
+ "inspect",
412
+ config.postgresql.image,
413
+ ]);
414
+ if (!result.available) {
415
+ throw new Error(
416
+ `PostgreSQL image ${config.postgresql.image} is unavailable locally.`,
417
+ );
418
+ }
419
+ return config.postgresql.image;
391
420
  },
392
- remediation: "Install the Supabase CLI in the project and retry.",
421
+ remediation:
422
+ config.runtime.target === "supabase"
423
+ ? "Install the Supabase CLI in the project and retry."
424
+ : `Pull and review ${config.postgresql.image}, then retry.`,
393
425
  },
394
426
  {
395
427
  id: "docker",
@@ -412,13 +444,17 @@ export const runRehearsalDoctor = async (options = {}) => {
412
444
  run: async () => {
413
445
  await Promise.all([
414
446
  verifyPath(paths.migrationDirectory, "directory"),
415
- verifyPath(paths.rehearsalConfig, "file"),
447
+ ...(paths.rehearsalConfig
448
+ ? [verifyPath(paths.rehearsalConfig, "file")]
449
+ : []),
416
450
  verifyPath(paths.sanitizationPolicy, "file"),
417
451
  ...(paths.runtimeAdapter
418
452
  ? [verifyPath(paths.runtimeAdapter, "file")]
419
453
  : []),
420
454
  ]);
421
- return "migrations, local runtime config, and sanitization policy exist";
455
+ return config.runtime.target === "supabase"
456
+ ? "migrations, local runtime config, and sanitization policy exist"
457
+ : "migrations and sanitization policy exist";
422
458
  },
423
459
  remediation:
424
460
  "Correct the missing project path in the Rehearsal configuration.",
@@ -446,6 +482,9 @@ export const runRehearsalDoctor = async (options = {}) => {
446
482
  id: "service-environment",
447
483
  label: "Local service credentials",
448
484
  run: async () => {
485
+ if (config.runtime.target === "postgresql") {
486
+ return "no local service credentials required";
487
+ }
449
488
  const environment = await readRehearsalServiceEnvironment({
450
489
  path: paths.serviceEnvironment,
451
490
  keys: config.supabase.serviceEnvironmentVariables,
@@ -472,6 +511,42 @@ export const runRehearsalDoctor = async (options = {}) => {
472
511
  );
473
512
  const occupied = availability.filter((entry) => !entry.available);
474
513
  if (occupied.length) {
514
+ if (config.runtime.target === "postgresql") {
515
+ const status = spawnSync(
516
+ "docker",
517
+ [
518
+ "ps",
519
+ "--filter",
520
+ `label=com.rehearsal-db.project=${config.runtime.projectId}`,
521
+ "--format",
522
+ "{{.Ports}}",
523
+ ],
524
+ {
525
+ cwd: projectRoot,
526
+ encoding: "utf8",
527
+ env: Object.fromEntries(
528
+ ["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TMPDIR"].flatMap(
529
+ (key) =>
530
+ process.env[key] === undefined
531
+ ? []
532
+ : [[key, process.env[key]]],
533
+ ),
534
+ ),
535
+ stdio: ["ignore", "pipe", "pipe"],
536
+ },
537
+ );
538
+ if (
539
+ status.status !== 0 ||
540
+ !String(status.stdout).includes(
541
+ `127.0.0.1:${config.runtime.ports.database}->5432/tcp`,
542
+ )
543
+ ) {
544
+ throw new Error(
545
+ `Configured database port is occupied by an unverified process: database=${config.runtime.ports.database}.`,
546
+ );
547
+ }
548
+ return "occupied only by the configured local Rehearsal runtime";
549
+ }
475
550
  const serviceEnvironment = await readRehearsalServiceEnvironment({
476
551
  path: paths.serviceEnvironment,
477
552
  keys: config.supabase.serviceEnvironmentVariables,
@@ -7,6 +7,31 @@
7
7
  import { readFile, writeFile } from "node:fs/promises";
8
8
  import { validateRuntimeSanitizationPolicy } from "./sanitization_policy.mjs";
9
9
 
10
+ export const SAFE_COLUMN_PRESET = Object.freeze({
11
+ action: "REPLACE WITH SYNTHETIC",
12
+ generated: "NEVER",
13
+ identity: "NO",
14
+ foreignKey: null,
15
+ });
16
+
17
+ export const createSafeTablePreset = (columns) =>
18
+ Object.fromEntries(
19
+ columns.map((column) => [
20
+ typeof column === "string" ? column : column.name,
21
+ { ...SAFE_COLUMN_PRESET },
22
+ ]),
23
+ );
24
+
25
+ export const suggestPolicyExceptionColumns = (columns) =>
26
+ columns
27
+ .map((column) => (typeof column === "string" ? column : column.name))
28
+ .filter(
29
+ (column) =>
30
+ column === "id" ||
31
+ column.endsWith("_id") ||
32
+ ["created_at", "updated_at", "deleted_at"].includes(column),
33
+ );
34
+
10
35
  export const readReviewablePolicyDraft = async (path) => {
11
36
  const source = await readFile(path, "utf8");
12
37
  const draft = JSON.parse(source);
@@ -18,18 +43,33 @@ export const readReviewablePolicyDraft = async (path) => {
18
43
  return { source, draft };
19
44
  };
20
45
 
21
- export const completePolicyDraft = async ({ draft, reviewColumn }) => {
46
+ export const completePolicyDraft = async ({
47
+ draft,
48
+ reviewColumn,
49
+ reviewTable,
50
+ }) => {
22
51
  if (draft?.draft !== true || !Array.isArray(draft.tables)) {
23
52
  throw new Error("A generated REVIEW REQUIRED policy draft is required.");
24
53
  }
25
54
  const tables = [];
26
55
  for (const table of draft.tables) {
56
+ const columnNames = (table.columns ?? []).map((column) => column.name);
57
+ const tableDecisions = reviewTable
58
+ ? await reviewTable({ table: table.name, columns: columnNames })
59
+ : {};
27
60
  const columns = [];
28
61
  for (const column of table.columns ?? []) {
29
- const reviewed = await reviewColumn({
30
- table: table.name,
31
- column: column.name,
32
- });
62
+ const reviewed =
63
+ tableDecisions?.[column.name] ??
64
+ (await reviewColumn?.({
65
+ table: table.name,
66
+ column: column.name,
67
+ }));
68
+ if (!reviewed) {
69
+ throw new Error(
70
+ `Policy review did not classify ${table.name}.${column.name}.`,
71
+ );
72
+ }
33
73
  columns.push({ name: column.name, ...reviewed });
34
74
  }
35
75
  tables.push({ ...table, columns });
@@ -236,7 +236,11 @@ const buildForeignKeyChecks = (manifest) =>
236
236
  })
237
237
  .join("\n\t");
238
238
 
239
- export const buildRestoreSqlSuffix = ({ manifest, tableCounts }) => {
239
+ export const buildRestoreSqlSuffix = ({
240
+ manifest,
241
+ tableCounts,
242
+ restoreSupabaseAuth = true,
243
+ }) => {
240
244
  if (!Array.isArray(manifest?.tables) || !tableCounts) {
241
245
  throw new Error("A reviewed manifest and exact row counts are required.");
242
246
  }
@@ -252,7 +256,7 @@ truncate table ${manifest.tables
252
256
  .map((table) => `public.${quoteIdentifier(table.name)}`)
253
257
  .join(", ")}
254
258
  restart identity cascade;
255
- ${buildAuthRestoreSql(manifest)}
259
+ ${restoreSupabaseAuth ? buildAuthRestoreSql(manifest) : ""}
256
260
  ${tables.map(buildTableRestoreSql).join("\n")}
257
261
  ${tables.map(buildIdentitySequenceSql).filter(Boolean).join("\n")}
258
262
  set local session_replication_role = origin;
@@ -9,7 +9,9 @@ import { dirname, join, relative, resolve } from "node:path";
9
9
  import {
10
10
  inspectDetectedProject,
11
11
  renderDetectedConfig,
12
+ renderDetectedPostgresqlConfig,
12
13
  } from "./configuration.mjs";
14
+ import { resolveRuntimeTarget } from "../runtime/runtime_target.mjs";
13
15
 
14
16
  const CONFIG_PATH = "rehearsal.config.mjs";
15
17
  const LOCAL_SUPABASE_CONFIG_PATH =
@@ -209,20 +211,26 @@ const assertCreateTargetAvailable = async (file) => {
209
211
  export const planRehearsalSetup = async ({
210
212
  projectRoot = process.cwd(),
211
213
  isPortAvailable,
214
+ target = "supabase",
212
215
  } = {}) => {
213
216
  const root = resolve(projectRoot);
217
+ const runtimeTarget = resolveRuntimeTarget(target).id;
214
218
  const detected = await inspectDetectedProject({ projectRoot: root });
215
219
  const ports = await findAvailableRehearsalPorts({
216
220
  ...(isPortAvailable ? { isAvailable: isPortAvailable } : {}),
217
221
  });
218
222
  const applicationUrl = "http://localhost:5175";
219
223
  const projectId = `${detected.projectName}-rehearsal`;
220
- const sourceSupabaseConfig = await readOptionalFile(
221
- join(root, "supabase/config.toml"),
222
- );
224
+ const sourceSupabaseConfig =
225
+ runtimeTarget === "supabase"
226
+ ? await readOptionalFile(join(root, "supabase/config.toml"))
227
+ : null;
223
228
  const originalGitignore = await readOptionalFile(join(root, GITIGNORE_PATH));
224
229
  const nextGitignore = appendGitignoreEntries(originalGitignore);
225
- for (const path of [CONFIG_PATH, LOCAL_SUPABASE_CONFIG_PATH]) {
230
+ for (const path of [
231
+ CONFIG_PATH,
232
+ ...(runtimeTarget === "supabase" ? [LOCAL_SUPABASE_CONFIG_PATH] : []),
233
+ ]) {
226
234
  if (await pathExists(join(root, path))) {
227
235
  throw new Error(
228
236
  `Rehearsal setup will not overwrite the existing file at ${path}.`,
@@ -235,23 +243,34 @@ export const planRehearsalSetup = async ({
235
243
  absolutePath: join(root, CONFIG_PATH),
236
244
  action: "create",
237
245
  mode: 0o600,
238
- content: renderDetectedConfig(detected, {
239
- applicationUrl,
240
- ports,
241
- }),
242
- },
243
- {
244
- path: LOCAL_SUPABASE_CONFIG_PATH,
245
- absolutePath: join(root, LOCAL_SUPABASE_CONFIG_PATH),
246
- action: "create",
247
- mode: 0o600,
248
- content: renderSafeLocalSupabaseConfig({
249
- projectId,
250
- applicationUrl,
251
- ports,
252
- databaseMajorVersion: databaseMajorVersionFrom(sourceSupabaseConfig),
253
- }),
246
+ content:
247
+ runtimeTarget === "supabase"
248
+ ? renderDetectedConfig(detected, {
249
+ applicationUrl,
250
+ ports,
251
+ })
252
+ : renderDetectedPostgresqlConfig(detected, {
253
+ applicationUrl,
254
+ databasePort: ports.database,
255
+ }),
254
256
  },
257
+ ...(runtimeTarget === "supabase"
258
+ ? [
259
+ {
260
+ path: LOCAL_SUPABASE_CONFIG_PATH,
261
+ absolutePath: join(root, LOCAL_SUPABASE_CONFIG_PATH),
262
+ action: "create",
263
+ mode: 0o600,
264
+ content: renderSafeLocalSupabaseConfig({
265
+ projectId,
266
+ applicationUrl,
267
+ ports,
268
+ databaseMajorVersion:
269
+ databaseMajorVersionFrom(sourceSupabaseConfig),
270
+ }),
271
+ },
272
+ ]
273
+ : []),
255
274
  {
256
275
  path: GITIGNORE_PATH,
257
276
  absolutePath: join(root, GITIGNORE_PATH),
@@ -270,15 +289,24 @@ export const planRehearsalSetup = async ({
270
289
  projectRoot: root,
271
290
  project: detected.projectName,
272
291
  projectId,
292
+ target: runtimeTarget,
273
293
  applicationUrl,
274
294
  detected,
275
- ports,
295
+ ports: runtimeTarget === "supabase" ? ports : { database: ports.database },
276
296
  isPortAvailable: isPortAvailable ?? isRehearsalPortAvailable,
277
297
  files,
278
298
  safety: [
279
299
  "hosted access remains disabled",
280
- "the Supabase runtime binds to dedicated local ports",
281
- "external identity providers, realtime, edge functions, and analytics start disabled",
300
+ runtimeTarget === "supabase"
301
+ ? "the Supabase runtime binds to dedicated local ports"
302
+ : "PostgreSQL binds only to a dedicated loopback port",
303
+ ...(runtimeTarget === "supabase"
304
+ ? [
305
+ "external identity providers, realtime, edge functions, and analytics start disabled",
306
+ ]
307
+ : [
308
+ "the runtime uses an already-installed official PostgreSQL image and never pulls implicitly",
309
+ ]),
282
310
  "configuration files are never overwritten and .gitignore updates are concurrency-checked",
283
311
  ],
284
312
  };
@@ -331,13 +359,17 @@ export const summarizeRehearsalSetup = (plan, { mode }) => ({
331
359
  mode,
332
360
  project: plan.project,
333
361
  projectId: plan.projectId,
362
+ target: plan.target,
334
363
  applicationUrl: plan.applicationUrl,
335
364
  detected: plan.detected,
336
- ports: {
337
- api: plan.ports.api,
338
- database: plan.ports.database,
339
- studio: plan.ports.studio,
340
- },
365
+ ports:
366
+ plan.target === "supabase"
367
+ ? {
368
+ api: plan.ports.api,
369
+ database: plan.ports.database,
370
+ studio: plan.ports.studio,
371
+ }
372
+ : { database: plan.ports.database },
341
373
  files: plan.files.map((file) => ({ path: file.path, action: file.action })),
342
374
  safety: plan.safety,
343
375
  nextAction:
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Build a copy-ready, data-free support report. The report intentionally omits
3
+ * project names, paths, command output, environment-variable names, row data,
4
+ * migration contents, and baseline identifiers.
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { arch, platform, release } from "node:os";
9
+ import { REHEARSAL_VERSION, redactDiagnosticValue } from "./diagnostics.mjs";
10
+ import { runRehearsalDoctor } from "./plan.mjs";
11
+
12
+ export const REHEARSAL_SUPPORT_URL =
13
+ "https://github.com/Ddupasquier/rehearsal-db/issues/new?template=bug.yml";
14
+
15
+ const safeEnvironment = () =>
16
+ Object.fromEntries(
17
+ ["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TMPDIR"].flatMap((key) =>
18
+ process.env[key] === undefined ? [] : [[key, process.env[key]]],
19
+ ),
20
+ );
21
+
22
+ const runVersionCommand = (command, args) =>
23
+ spawnSync(command, args, {
24
+ encoding: "utf8",
25
+ env: safeEnvironment(),
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ timeout: 5_000,
28
+ });
29
+
30
+ const inspectTool = ({ command, args, runCommand }) => {
31
+ const result = runCommand(command, args);
32
+ if (result.error?.code === "ENOENT") return { status: "not_installed" };
33
+ if (result.status !== 0) return { status: "unavailable" };
34
+ const version = String(result.stdout || result.stderr)
35
+ .trim()
36
+ .split(/\r?\n/u)[0]
37
+ .slice(0, 200);
38
+ return {
39
+ status: "available",
40
+ version: redactDiagnosticValue(version || "version not reported"),
41
+ };
42
+ };
43
+
44
+ export const collectRehearsalSupportReport = async (
45
+ options = {},
46
+ {
47
+ runDoctor = runRehearsalDoctor,
48
+ runCommand = runVersionCommand,
49
+ system = {
50
+ platform: platform(),
51
+ release: release(),
52
+ architecture: arch(),
53
+ node: process.versions.node,
54
+ },
55
+ } = {},
56
+ ) => {
57
+ const doctor = await runDoctor(options);
58
+ return {
59
+ rehearsalVersion: REHEARSAL_VERSION,
60
+ system,
61
+ tools: {
62
+ npm: inspectTool({ command: "npm", args: ["--version"], runCommand }),
63
+ supabase: inspectTool({
64
+ command: "supabase",
65
+ args: ["--version"],
66
+ runCommand,
67
+ }),
68
+ dockerClient: inspectTool({
69
+ command: "docker",
70
+ args: ["--version"],
71
+ runCommand,
72
+ }),
73
+ dockerServer: inspectTool({
74
+ command: "docker",
75
+ args: ["info", "--format", "{{.ServerVersion}}"],
76
+ runCommand,
77
+ }),
78
+ },
79
+ readiness: {
80
+ state: doctor.state,
81
+ checks: doctor.checks.map(({ id, label, status, remediation }) => ({
82
+ id,
83
+ label,
84
+ status,
85
+ ...(status === "fail" && remediation ? { remediation } : {}),
86
+ })),
87
+ quarantinedHostedVariableCount:
88
+ doctor.ambientHostedVariables?.presentButQuarantined.length ?? 0,
89
+ },
90
+ privacy: {
91
+ omits: [
92
+ "row values",
93
+ "credentials",
94
+ "project paths",
95
+ "migration SQL",
96
+ "baseline identifiers",
97
+ ],
98
+ reviewBeforeSharing: true,
99
+ },
100
+ supportUrl: REHEARSAL_SUPPORT_URL,
101
+ };
102
+ };