@rehearsal-db/core 0.1.0-beta.5 → 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.
package/docs/tutorial.md CHANGED
@@ -1,96 +1,107 @@
1
- # Tutorial: rehearse a migration in a new project
1
+ # Safe hands-on tutorial
2
2
 
3
- This walkthrough uses a fictional `widgets` application. It demonstrates the complete
4
- consumer workflow without importing private or hosted data.
3
+ This tutorial runs a complete PostgreSQL rehearsal in a temporary fictional project. It
4
+ does not use your application, production data, or a hosted database.
5
5
 
6
- ## Create the application migration history
6
+ Allow about five minutes. You need Node.js 24 and a running Docker-compatible engine.
7
7
 
8
- The project begins with one historical migration:
8
+ ## 1. Prepare Rehearsal
9
9
 
10
- ```sql
11
- create table public.widgets (
12
- id bigint generated always as identity primary key,
13
- name text not null
14
- );
10
+ If you do not already have this repository, clone it:
11
+
12
+ ```bash
13
+ git clone https://github.com/Ddupasquier/rehearsal-db.git
14
+ cd rehearsal-db
15
15
  ```
16
16
 
17
- Place it at `supabase/migrations/20260101000000_create_widgets.sql`. Add a second,
18
- candidate migration:
17
+ Then run:
19
18
 
20
- ```sql
21
- alter table public.widgets add column description text;
19
+ ```bash
20
+ npm ci --ignore-scripts
21
+ docker pull postgres:17-alpine
22
22
  ```
23
23
 
24
- Place that at `supabase/migrations/20260101000100_add_widget_description.sql`.
24
+ If you already have this repository open, use that checkout.
25
25
 
26
- ## Configure the isolated runtime
26
+ ## 2. Make a temporary project
27
27
 
28
- Run `npx rehearsal init --write`, then edit the result. Give the runtime unique local
29
- ports and a unique project ID. Its `rehearsalConfig` must point at a dedicated, unlinked
30
- Supabase config. Never reuse a hosted project reference or production environment file.
28
+ From the Rehearsal repository root, run this block exactly:
31
29
 
32
- Keep the generated `.rehearsal` directory ignored. It contains local artifacts and
33
- runtime state, not source code.
30
+ ```bash
31
+ rehearsal_repo=$PWD
32
+ tutorial_dir=$(mktemp -d)
33
+ cp -R tests/fixtures/postgresql-project "$tutorial_dir/app"
34
+ cd "$tutorial_dir/app"
35
+ npm install --ignore-scripts --no-save "$rehearsal_repo"
36
+ ```
34
37
 
35
- ## Build a synthetic baseline
38
+ The temporary project contains:
36
39
 
37
- The baseline contains the historical migration bundle, a one-row sanitized data stream,
38
- and a manifest binding their checksums. Baseline construction is deliberately separate
39
- from runtime execution: the application owns extraction and policy; the engine accepts
40
- only a completed, verified artifact.
40
+ - one historical migration that creates a `widgets` table;
41
+ - one candidate migration that adds a `description` column;
42
+ - one synthetic row;
43
+ - a reviewed sanitization policy;
44
+ - an application proof that checks the migrated database.
41
45
 
42
- Write the safe rows to `rehearsal/synthetic-data.ndjson` and the exact represented
43
- statements to `rehearsal/migration-ledger.json`, then run:
46
+ ## 3. Create the baseline
47
+
48
+ Open the guide:
44
49
 
45
50
  ```bash
46
- npx rehearsal baseline create \
47
- --records=rehearsal/synthetic-data.ndjson \
48
- --ledger=rehearsal/migration-ledger.json
51
+ npx rehearsal
49
52
  ```
50
53
 
51
- For an executable sample, inspect `tests/fixtures/rehearsal-project` in the repository.
52
- The maintained fixture proof invokes this public command through the packed npm tarball
53
- and never contacts a hosted service.
54
+ The project begins at Stage 3 of 4. Choose **Create the baseline**. Accept the detected
55
+ record and ledger files, review the summary, then confirm creation.
54
56
 
55
- ## Prove planning is non-mutating
57
+ The project should advance to Stage 4 of 4 with every item checked.
56
58
 
57
- ```bash
58
- npx rehearsal doctor
59
- npx rehearsal explain
60
- npx rehearsal run --dry-run
61
- npx rehearsal candidates --json
59
+ ## 4. Run the migration
60
+
61
+ Choose **Run a rehearsal**. The guide should show exactly one candidate:
62
+
63
+ ```text
64
+ 20260101000100_add_widget_description.sql
62
65
  ```
63
66
 
64
- `explain` and `run --dry-run` return the same execution plan. At this point no local
65
- database has been restored.
67
+ Confirm it. Rehearsal creates a loopback-only PostgreSQL container, restores the baseline,
68
+ applies the candidate, and runs the fixture proof.
66
69
 
67
- ## Run and inspect
70
+ Success ends with an application-proof message and suggests exercising the local
71
+ application before verification.
68
72
 
69
- Copy the exact digest from `candidates`:
73
+ ## 5. Try the runtime commands
74
+
75
+ Exit the guide, then run:
70
76
 
71
77
  ```bash
72
- npx rehearsal run --confirm-candidates=<sha256>
73
- npx rehearsal inspect migrations
74
78
  npx rehearsal status
79
+ npx rehearsal verify
80
+ npx rehearsal reset
81
+ npx rehearsal stop
82
+ npx rehearsal discard
75
83
  ```
76
84
 
77
- The historical migration should be `represented_by_baseline`; the description migration
78
- should be `applied_to_current_runtime`. Test normal creates, updates, and deletes through
79
- your local application. They affect only this disposable database.
85
+ `discard` removes only the labeled tutorial container and volume. The temporary project
86
+ directory remains on disk and can be deleted when you no longer need it.
87
+
88
+ ## What you proved
80
89
 
81
- ## Prove failure behavior
90
+ You used the same packaged CLI a normal project installs. Rehearsal verified the baseline,
91
+ approved an exact migration, ran it in a disposable database, tested the result, and
92
+ cleaned up only its own runtime.
82
93
 
83
- Add a timestamped migration containing invalid SQL, rerun `candidates`, and use its new
84
- digest. The command must fail with `migration_candidate_failure`, remove the untrusted
85
- runtime, and never produce a successful receipt.
94
+ Next, follow [Getting started](getting-started.md) in your own project. Start with synthetic
95
+ rows until the workflow and application proof are reliable.
86
96
 
87
- Then restore the valid migration set and rerun:
97
+ ## Supabase check
98
+
99
+ If Supabase CLI 2.117.0 is installed, the repository also has a fully automated Supabase
100
+ proof:
88
101
 
89
102
  ```bash
90
- npx rehearsal reset
91
- npx rehearsal verify
92
- npx rehearsal stop
103
+ cd "$rehearsal_repo"
104
+ npm run test:fixture
93
105
  ```
94
106
 
95
- That cycle—plan, confirm exact bytes, run, exercise the app, reset—is the normal Rehearsal
96
- workflow.
107
+ The PostgreSQL equivalent is `npm run test:fixture:postgresql`.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@rehearsal-db/core",
3
- "version": "0.1.0-beta.5",
3
+ "version": "0.1.0-beta.6",
4
4
  "private": false,
5
- "description": "Safely rehearse Supabase migrations against sanitized, production-shaped PostgreSQL data.",
5
+ "description": "Safely rehearse PostgreSQL and Supabase migrations against sanitized, production-shaped data.",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/Ddupasquier/rehearsal-db.git"
@@ -39,6 +39,7 @@
39
39
  "files": [
40
40
  "scripts/lib/environment/local_supabase.mjs",
41
41
  "scripts/lib/rehearsal",
42
+ "scripts/lib/runtime",
42
43
  "scripts/operations/database/manage_rehearsal_database.mjs",
43
44
  "scripts/operations/rehearsal/rehearsal_cli.mjs",
44
45
  "docs/*.md",
@@ -55,6 +56,7 @@
55
56
  "test:unit": "vitest run tests/scripts",
56
57
  "test:docs": "vitest run tests/contracts/documentationCommands.test.mjs",
57
58
  "test:fixture": "node scripts/operations/rehearsal/prove_independent_fixture.mjs",
59
+ "test:fixture:postgresql": "node scripts/operations/rehearsal/prove_postgresql_fixture.mjs",
58
60
  "check": "npm run check:syntax && npm run test && npm run format:check && npm run package:audit",
59
61
  "check:syntax": "find scripts tests -type f -name '*.mjs' -exec node --check {} +",
60
62
  "format": "prettier --write .",
@@ -3,7 +3,7 @@ export type RehearsalConfigVersion = 1;
3
3
  export interface RehearsalConfig {
4
4
  schemaVersion: RehearsalConfigVersion;
5
5
  project: { name: string };
6
- supabase: {
6
+ supabase?: {
7
7
  workdir: string;
8
8
  migrationDirectory: string;
9
9
  rehearsalConfig: string;
@@ -11,6 +11,13 @@ export interface RehearsalConfig {
11
11
  serviceEnvironmentFile?: string;
12
12
  serviceEnvironmentVariables?: string[];
13
13
  };
14
+ postgresql?: {
15
+ migrationDirectory: string;
16
+ runtimeWorkdir?: string;
17
+ image?: string;
18
+ database?: string;
19
+ user?: string;
20
+ };
14
21
  baseline: {
15
22
  artifactDirectory?: string;
16
23
  sanitizationPolicy: string;
@@ -22,11 +29,12 @@ export interface RehearsalConfig {
22
29
  runtimeAdapter?: string;
23
30
  };
24
31
  runtime: {
32
+ target?: "supabase" | "postgresql";
25
33
  applicationUrl?: string;
26
34
  projectId?: string;
27
- apiPort: number;
35
+ apiPort?: number;
28
36
  databasePort: number;
29
- studioPort: number;
37
+ studioPort?: number;
30
38
  };
31
39
  safety?: {
32
40
  allowedHosts?: string[];
@@ -58,6 +66,13 @@ export declare const renderDetectedConfig: (
58
66
  ports?: { api: number; database: number; studio: number };
59
67
  },
60
68
  ) => string;
69
+ export declare const renderDetectedPostgresqlConfig: (
70
+ detected: unknown,
71
+ options?: {
72
+ applicationUrl?: string;
73
+ databasePort?: number;
74
+ },
75
+ ) => string;
61
76
  export declare const SANITIZATION_ACTIONS: Readonly<{
62
77
  KEEP: "KEEP";
63
78
  PSEUDONYMIZE: "PSEUDONYMIZE";
@@ -7,6 +7,7 @@
7
7
  import { access, readFile, stat } from "node:fs/promises";
8
8
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
+ import { resolveRuntimeTarget } from "../runtime/runtime_target.mjs";
10
11
 
11
12
  export {
12
13
  EXCLUDED_VALUE,
@@ -29,6 +30,11 @@ export const REHEARSAL_DEFAULTS = Object.freeze({
29
30
  safety: Object.freeze({
30
31
  allowedHosts: Object.freeze(["127.0.0.1", "::1", "localhost"]),
31
32
  blockedEnvironmentVariables: Object.freeze([
33
+ "DATABASE_URL",
34
+ "PGHOST",
35
+ "PGPASSWORD",
36
+ "PGPORT",
37
+ "PGUSER",
32
38
  "SUPABASE_ACCESS_TOKEN",
33
39
  "SUPABASE_DB_PASSWORD",
34
40
  "SUPABASE_PROJECT_ID",
@@ -48,6 +54,8 @@ const CONFIG_FILENAMES = Object.freeze([
48
54
 
49
55
  const LOOPBACK_HOSTS = new Set(REHEARSAL_DEFAULTS.safety.allowedHosts);
50
56
  const IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}$/u;
57
+ const DATABASE_IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]{0,62}$/u;
58
+ const POSTGRES_IMAGE_PATTERN = /^postgres:\d+(?:\.\d+)?-alpine$/u;
51
59
  const SAFE_COMMAND_PATTERN = /^[^\n\r\0]+$/u;
52
60
 
53
61
  const isPlainObject = (value) =>
@@ -141,6 +149,7 @@ const normalizeConfig = (input) => {
141
149
  "schemaVersion",
142
150
  "project",
143
151
  "supabase",
152
+ "postgresql",
144
153
  "baseline",
145
154
  "application",
146
155
  "runtime",
@@ -164,19 +173,46 @@ const normalizeConfig = (input) => {
164
173
  );
165
174
  }
166
175
 
167
- const supabase = assertPlainObject(root.supabase, "config.supabase");
168
- assertKnownKeys(
169
- supabase,
170
- [
171
- "workdir",
172
- "migrationDirectory",
173
- "rehearsalConfig",
174
- "runtimeWorkdir",
175
- "serviceEnvironmentFile",
176
- "serviceEnvironmentVariables",
177
- ],
178
- "config.supabase",
179
- );
176
+ const runtimeTarget = resolveRuntimeTarget(root.runtime?.target).id;
177
+ const supabase =
178
+ runtimeTarget === "supabase"
179
+ ? assertPlainObject(root.supabase, "config.supabase")
180
+ : null;
181
+ const postgresql =
182
+ runtimeTarget === "postgresql"
183
+ ? assertPlainObject(root.postgresql, "config.postgresql")
184
+ : null;
185
+ if (runtimeTarget === "supabase" && root.postgresql !== undefined) {
186
+ throw new Error(
187
+ "config.postgresql is not allowed when config.runtime.target is supabase.",
188
+ );
189
+ }
190
+ if (runtimeTarget === "postgresql" && root.supabase !== undefined) {
191
+ throw new Error(
192
+ "config.supabase is not allowed when config.runtime.target is postgresql.",
193
+ );
194
+ }
195
+ if (supabase) {
196
+ assertKnownKeys(
197
+ supabase,
198
+ [
199
+ "workdir",
200
+ "migrationDirectory",
201
+ "rehearsalConfig",
202
+ "runtimeWorkdir",
203
+ "serviceEnvironmentFile",
204
+ "serviceEnvironmentVariables",
205
+ ],
206
+ "config.supabase",
207
+ );
208
+ }
209
+ if (postgresql) {
210
+ assertKnownKeys(
211
+ postgresql,
212
+ ["migrationDirectory", "runtimeWorkdir", "image", "database", "user"],
213
+ "config.postgresql",
214
+ );
215
+ }
180
216
  const baseline = assertPlainObject(root.baseline, "config.baseline");
181
217
  assertKnownKeys(
182
218
  baseline,
@@ -192,7 +228,14 @@ const normalizeConfig = (input) => {
192
228
  const runtime = assertPlainObject(root.runtime ?? {}, "config.runtime");
193
229
  assertKnownKeys(
194
230
  runtime,
195
- ["applicationUrl", "projectId", "apiPort", "databasePort", "studioPort"],
231
+ [
232
+ "target",
233
+ "applicationUrl",
234
+ "projectId",
235
+ "apiPort",
236
+ "databasePort",
237
+ "studioPort",
238
+ ],
196
239
  "config.runtime",
197
240
  );
198
241
  const safety = assertPlainObject(root.safety ?? {}, "config.safety");
@@ -234,9 +277,13 @@ const normalizeConfig = (input) => {
234
277
  }
235
278
 
236
279
  const ports = {
237
- api: assertPort(runtime.apiPort, "config.runtime.apiPort"),
280
+ ...(runtimeTarget === "supabase"
281
+ ? { api: assertPort(runtime.apiPort, "config.runtime.apiPort") }
282
+ : {}),
238
283
  database: assertPort(runtime.databasePort, "config.runtime.databasePort"),
239
- studio: assertPort(runtime.studioPort, "config.runtime.studioPort"),
284
+ ...(runtimeTarget === "supabase"
285
+ ? { studio: assertPort(runtime.studioPort, "config.runtime.studioPort") }
286
+ : {}),
240
287
  };
241
288
  if (new Set(Object.values(ports)).size !== Object.values(ports).length) {
242
289
  throw new Error("Rehearsal runtime ports must be unique.");
@@ -247,7 +294,7 @@ const normalizeConfig = (input) => {
247
294
  throw new Error("config.verification.commands must be an array.");
248
295
  }
249
296
  const serviceEnvironmentVariables =
250
- supabase.serviceEnvironmentVariables ?? [];
297
+ supabase?.serviceEnvironmentVariables ?? [];
251
298
  if (!Array.isArray(serviceEnvironmentVariables)) {
252
299
  throw new Error(
253
300
  "config.supabase.serviceEnvironmentVariables must be an array.",
@@ -269,41 +316,105 @@ const normalizeConfig = (input) => {
269
316
  );
270
317
  }
271
318
  if (
272
- Boolean(supabase.serviceEnvironmentFile) !==
319
+ Boolean(supabase?.serviceEnvironmentFile) !==
273
320
  Boolean(normalizedServiceEnvironmentVariables.length)
274
321
  ) {
275
322
  throw new Error(
276
323
  "config.supabase.serviceEnvironmentFile and serviceEnvironmentVariables must be configured together.",
277
324
  );
278
325
  }
326
+ const authenticationProviders = safety.authenticationProviders
327
+ ? assertStringArray(
328
+ safety.authenticationProviders,
329
+ "config.safety.authenticationProviders",
330
+ )
331
+ : [];
332
+ if (runtimeTarget === "postgresql" && authenticationProviders.length > 0) {
333
+ throw new Error(
334
+ "config.safety.authenticationProviders is available only for the Supabase target.",
335
+ );
336
+ }
279
337
 
280
338
  return Object.freeze({
281
339
  schemaVersion: REHEARSAL_CONFIG_VERSION,
282
340
  project: Object.freeze({ name: projectName }),
283
- supabase: Object.freeze({
284
- workdir: assertRelativePath(supabase.workdir, "config.supabase.workdir"),
285
- migrationDirectory: assertRelativePath(
286
- supabase.migrationDirectory,
287
- "config.supabase.migrationDirectory",
288
- ),
289
- rehearsalConfig: assertRelativePath(
290
- supabase.rehearsalConfig,
291
- "config.supabase.rehearsalConfig",
292
- ),
293
- runtimeWorkdir: assertRelativePath(
294
- supabase.runtimeWorkdir,
295
- "config.supabase.runtimeWorkdir",
296
- ),
297
- serviceEnvironmentFile: supabase.serviceEnvironmentFile
298
- ? assertRelativePath(
299
- supabase.serviceEnvironmentFile,
300
- "config.supabase.serviceEnvironmentFile",
301
- )
302
- : null,
303
- serviceEnvironmentVariables: Object.freeze(
304
- normalizedServiceEnvironmentVariables,
305
- ),
306
- }),
341
+ supabase: supabase
342
+ ? Object.freeze({
343
+ workdir: assertRelativePath(
344
+ supabase.workdir,
345
+ "config.supabase.workdir",
346
+ ),
347
+ migrationDirectory: assertRelativePath(
348
+ supabase.migrationDirectory,
349
+ "config.supabase.migrationDirectory",
350
+ ),
351
+ rehearsalConfig: assertRelativePath(
352
+ supabase.rehearsalConfig,
353
+ "config.supabase.rehearsalConfig",
354
+ ),
355
+ runtimeWorkdir: assertRelativePath(
356
+ supabase.runtimeWorkdir,
357
+ "config.supabase.runtimeWorkdir",
358
+ ),
359
+ serviceEnvironmentFile: supabase.serviceEnvironmentFile
360
+ ? assertRelativePath(
361
+ supabase.serviceEnvironmentFile,
362
+ "config.supabase.serviceEnvironmentFile",
363
+ )
364
+ : null,
365
+ serviceEnvironmentVariables: Object.freeze(
366
+ normalizedServiceEnvironmentVariables,
367
+ ),
368
+ })
369
+ : null,
370
+ postgresql: postgresql
371
+ ? Object.freeze({
372
+ migrationDirectory: assertRelativePath(
373
+ postgresql.migrationDirectory,
374
+ "config.postgresql.migrationDirectory",
375
+ ),
376
+ runtimeWorkdir: assertRelativePath(
377
+ postgresql.runtimeWorkdir ?? ".rehearsal/runtime",
378
+ "config.postgresql.runtimeWorkdir",
379
+ ),
380
+ image: (() => {
381
+ const image = assertNonEmptyString(
382
+ postgresql.image ?? "postgres:17-alpine",
383
+ "config.postgresql.image",
384
+ );
385
+ if (!POSTGRES_IMAGE_PATTERN.test(image)) {
386
+ throw new Error(
387
+ "config.postgresql.image must name an official versioned Alpine PostgreSQL image, such as postgres:17-alpine.",
388
+ );
389
+ }
390
+ return image;
391
+ })(),
392
+ database: (() => {
393
+ const database = assertNonEmptyString(
394
+ postgresql.database ?? "postgres",
395
+ "config.postgresql.database",
396
+ );
397
+ if (!DATABASE_IDENTIFIER_PATTERN.test(database)) {
398
+ throw new Error(
399
+ "config.postgresql.database must be a safe lowercase PostgreSQL identifier.",
400
+ );
401
+ }
402
+ return database;
403
+ })(),
404
+ user: (() => {
405
+ const user = assertNonEmptyString(
406
+ postgresql.user ?? "postgres",
407
+ "config.postgresql.user",
408
+ );
409
+ if (!DATABASE_IDENTIFIER_PATTERN.test(user)) {
410
+ throw new Error(
411
+ "config.postgresql.user must be a safe lowercase PostgreSQL identifier.",
412
+ );
413
+ }
414
+ return user;
415
+ })(),
416
+ })
417
+ : null,
307
418
  baseline: Object.freeze({
308
419
  artifactDirectory: assertRelativePath(
309
420
  baseline.artifactDirectory ??
@@ -337,6 +448,7 @@ const normalizeConfig = (input) => {
337
448
  ),
338
449
  }),
339
450
  runtime: Object.freeze({
451
+ target: runtimeTarget,
340
452
  applicationUrl: assertLoopbackUrl(
341
453
  runtime.applicationUrl ?? REHEARSAL_DEFAULTS.runtime.applicationUrl,
342
454
  "config.runtime.applicationUrl",
@@ -357,14 +469,7 @@ const normalizeConfig = (input) => {
357
469
  "config.safety.blockedEnvironmentVariables",
358
470
  ),
359
471
  ),
360
- authenticationProviders: Object.freeze(
361
- safety.authenticationProviders
362
- ? assertStringArray(
363
- safety.authenticationProviders,
364
- "config.safety.authenticationProviders",
365
- )
366
- : [],
367
- ),
472
+ authenticationProviders: Object.freeze(authenticationProviders),
368
473
  hostedAccess: "disabled",
369
474
  outboundNetwork: "deny",
370
475
  }),
@@ -428,14 +533,22 @@ export const loadRehearsalConfig = async ({
428
533
  applicationEnvironment: resolveOwnedPath(
429
534
  config.application.environmentFile,
430
535
  ),
431
- migrationDirectory: resolveOwnedPath(config.supabase.migrationDirectory),
432
- rehearsalConfig: resolveOwnedPath(config.supabase.rehearsalConfig),
433
- runtimeWorkdir: resolveOwnedPath(config.supabase.runtimeWorkdir),
434
- serviceEnvironment: config.supabase.serviceEnvironmentFile
536
+ migrationDirectory: resolveOwnedPath(
537
+ (config.supabase ?? config.postgresql).migrationDirectory,
538
+ ),
539
+ rehearsalConfig: config.supabase
540
+ ? resolveOwnedPath(config.supabase.rehearsalConfig)
541
+ : null,
542
+ runtimeWorkdir: resolveOwnedPath(
543
+ (config.supabase ?? config.postgresql).runtimeWorkdir,
544
+ ),
545
+ serviceEnvironment: config.supabase?.serviceEnvironmentFile
435
546
  ? resolveOwnedPath(config.supabase.serviceEnvironmentFile)
436
547
  : null,
437
548
  sanitizationPolicy: resolveOwnedPath(config.baseline.sanitizationPolicy),
438
- supabaseWorkdir: resolveOwnedPath(config.supabase.workdir),
549
+ supabaseWorkdir: config.supabase
550
+ ? resolveOwnedPath(config.supabase.workdir)
551
+ : null,
439
552
  runtimeAdapter: config.application.runtimeAdapter
440
553
  ? resolveOwnedPath(config.application.runtimeAdapter)
441
554
  : null,
@@ -448,7 +561,7 @@ export const loadRehearsalConfig = async ({
448
561
  const requiredRuntimeWorkdir = join(paths.artifactDirectory, "runtime");
449
562
  if (paths.runtimeWorkdir !== requiredRuntimeWorkdir) {
450
563
  throw new Error(
451
- "config.supabase.runtimeWorkdir must resolve to the runtime directory inside config.baseline.artifactDirectory.",
564
+ `config.${config.runtime.target}.runtimeWorkdir must resolve to the runtime directory inside config.baseline.artifactDirectory.`,
452
565
  );
453
566
  }
454
567
  const environmentRelativePath = relative(
@@ -496,6 +609,13 @@ export const inspectDetectedProject = async ({
496
609
  ? "yarn"
497
610
  : "npm";
498
611
  const scripts = packageJson.scripts ?? {};
612
+ const postgresqlMigrationDirectory = (
613
+ await Promise.all(
614
+ ["database/migrations", "db/migrations", "migrations"].map(
615
+ async (path) => ({ path, exists: await hasPath(path) }),
616
+ ),
617
+ )
618
+ ).find(({ exists }) => exists)?.path;
499
619
  const normalizedProjectName = String(packageJson.name ?? basename(root))
500
620
  .toLowerCase()
501
621
  .replace(/[^a-z0-9-]+/gu, "-")
@@ -508,6 +628,9 @@ export const inspectDetectedProject = async ({
508
628
  packageManager,
509
629
  hasSupabaseConfig: await hasPath("supabase/config.toml"),
510
630
  hasMigrations: await hasPath("supabase/migrations"),
631
+ hasPostgresqlMigrations: Boolean(postgresqlMigrationDirectory),
632
+ postgresqlMigrationDirectory:
633
+ postgresqlMigrationDirectory ?? "database/migrations",
511
634
  applicationCommand:
512
635
  ["dev:rehearsal", "dev", "start"]
513
636
  .find((name) => typeof scripts[name] === "string")
@@ -548,6 +671,7 @@ export default defineRehearsalConfig({
548
671
  environmentFile: ".rehearsal/runtime.env",
549
672
  },
550
673
  runtime: {
674
+ target: "supabase",
551
675
  applicationUrl: ${JSON.stringify(applicationUrl)},
552
676
  projectId: "${detected.projectName}-rehearsal",
553
677
  apiPort: ${ports.api},
@@ -567,3 +691,50 @@ export default defineRehearsalConfig({
567
691
  verification: { commands: [${JSON.stringify(detected.verificationCommand)}] },
568
692
  });
569
693
  `;
694
+
695
+ export const renderDetectedPostgresqlConfig = (
696
+ detected,
697
+ { applicationUrl = "http://localhost:5175", databasePort = 58322 } = {},
698
+ ) => `// @ts-check
699
+ import { defineRehearsalConfig } from "@rehearsal-db/core";
700
+
701
+ export default defineRehearsalConfig({
702
+ schemaVersion: 1,
703
+ project: { name: ${JSON.stringify(detected.projectName)} },
704
+ postgresql: {
705
+ migrationDirectory: ${JSON.stringify(detected.postgresqlMigrationDirectory)},
706
+ runtimeWorkdir: ".rehearsal/runtime",
707
+ image: "postgres:17-alpine",
708
+ database: "postgres",
709
+ user: "postgres",
710
+ },
711
+ baseline: {
712
+ artifactDirectory: ".rehearsal",
713
+ sanitizationPolicy: "infrastructure/rehearsal/sanitization-policy.json",
714
+ },
715
+ application: {
716
+ startCommand: ${JSON.stringify(detected.applicationCommand)},
717
+ proofCommand: ${JSON.stringify(detected.verificationCommand)},
718
+ environmentFile: ".rehearsal/runtime.env",
719
+ },
720
+ runtime: {
721
+ target: "postgresql",
722
+ applicationUrl: ${JSON.stringify(applicationUrl)},
723
+ projectId: "${detected.projectName}-rehearsal",
724
+ databasePort: ${databasePort},
725
+ },
726
+ safety: {
727
+ allowedHosts: ["127.0.0.1", "::1", "localhost"],
728
+ blockedEnvironmentVariables: [
729
+ "DATABASE_URL",
730
+ "PGHOST",
731
+ "PGPASSWORD",
732
+ "PGPORT",
733
+ "PGUSER",
734
+ ],
735
+ hostedAccess: "disabled",
736
+ outboundNetwork: "deny",
737
+ },
738
+ verification: { commands: [${JSON.stringify(detected.verificationCommand)}] },
739
+ });
740
+ `;
@@ -7,7 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
 
9
9
  export const REHEARSAL_RESULT_VERSION = 1;
10
- export const REHEARSAL_VERSION = "0.1.0-beta.5";
10
+ export const REHEARSAL_VERSION = "0.1.0-beta.6";
11
11
 
12
12
  export const REHEARSAL_EXIT_CODES = Object.freeze({
13
13
  success: 0,
@@ -109,7 +109,9 @@ const inferCategory = (error) => {
109
109
  return "unsafe_environment";
110
110
  }
111
111
  if (
112
- /docker|supabase|runtime|command|enoent|node\.js|ports?\b/iu.test(message)
112
+ /docker|postgres(?:ql)?|supabase|runtime|command|enoent|node\.js|ports?\b/iu.test(
113
+ message,
114
+ )
113
115
  ) {
114
116
  return "runtime_dependency_failure";
115
117
  }