@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/CHANGELOG.md +31 -1
- package/COMPATIBILITY.md +5 -0
- package/README.md +105 -377
- package/SECURITY.md +2 -1
- package/docs/adapters.md +5 -0
- package/docs/baselines.md +81 -22
- package/docs/commands.md +76 -75
- package/docs/configuration.md +47 -4
- package/docs/getting-started.md +88 -227
- package/docs/glossary.md +7 -8
- package/docs/security-model.md +5 -1
- package/docs/troubleshooting.md +46 -1
- package/docs/tutorial.md +70 -59
- package/package.json +4 -2
- package/scripts/lib/rehearsal/configuration.d.mts +18 -3
- package/scripts/lib/rehearsal/configuration.mjs +227 -56
- package/scripts/lib/rehearsal/diagnostics.mjs +4 -2
- package/scripts/lib/rehearsal/plan.mjs +85 -10
- package/scripts/lib/rehearsal/runtime_restore.mjs +6 -2
- package/scripts/lib/rehearsal/setup.mjs +60 -28
- package/scripts/lib/runtime/postgresql_runtime.mjs +772 -0
- package/scripts/lib/runtime/runtime_target.mjs +41 -0
- package/scripts/lib/runtime/supabase_runtime.mjs +790 -0
- package/scripts/operations/database/manage_rehearsal_database.mjs +6 -785
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +138 -30
package/docs/tutorial.md
CHANGED
|
@@ -1,96 +1,107 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Safe hands-on tutorial
|
|
2
2
|
|
|
3
|
-
This
|
|
4
|
-
|
|
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
|
-
|
|
6
|
+
Allow about five minutes. You need Node.js 24 and a running Docker-compatible engine.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
## 1. Prepare Rehearsal
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
candidate migration:
|
|
17
|
+
Then run:
|
|
19
18
|
|
|
20
|
-
```
|
|
21
|
-
|
|
19
|
+
```bash
|
|
20
|
+
npm ci --ignore-scripts
|
|
21
|
+
docker pull postgres:17-alpine
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
If you already have this repository open, use that checkout.
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## 2. Make a temporary project
|
|
27
27
|
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
+
The temporary project contains:
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
46
|
+
## 3. Create the baseline
|
|
47
|
+
|
|
48
|
+
Open the guide:
|
|
44
49
|
|
|
45
50
|
```bash
|
|
46
|
-
npx rehearsal
|
|
47
|
-
--records=rehearsal/synthetic-data.ndjson \
|
|
48
|
-
--ledger=rehearsal/migration-ledger.json
|
|
51
|
+
npx rehearsal
|
|
49
52
|
```
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
+
The project should advance to Stage 4 of 4 with every item checked.
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
+
Success ends with an application-proof message and suggests exercising the local
|
|
71
|
+
application before verification.
|
|
68
72
|
|
|
69
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
npx rehearsal stop
|
|
103
|
+
cd "$rehearsal_repo"
|
|
104
|
+
npm run test:fixture
|
|
93
105
|
```
|
|
94
106
|
|
|
95
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.0-beta.6",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Safely rehearse Supabase migrations against sanitized, production-shaped
|
|
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
|
|
35
|
+
apiPort?: number;
|
|
28
36
|
databasePort: number;
|
|
29
|
-
studioPort
|
|
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
|
|
168
|
-
|
|
169
|
-
supabase
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
"
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
280
|
+
...(runtimeTarget === "supabase"
|
|
281
|
+
? { api: assertPort(runtime.apiPort, "config.runtime.apiPort") }
|
|
282
|
+
: {}),
|
|
238
283
|
database: assertPort(runtime.databasePort, "config.runtime.databasePort"),
|
|
239
|
-
|
|
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
|
|
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
|
|
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:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
supabase.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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(
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
}
|