@rehearsal-db/core 0.1.0-beta.2 → 0.1.0-beta.4
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 +49 -1
- package/README.md +27 -2
- package/docs/commands.md +33 -2
- package/docs/getting-started.md +41 -7
- package/docs/releasing.md +1 -1
- package/docs/sanitization.md +16 -0
- package/docs/troubleshooting.md +18 -2
- package/package.json +5 -2
- package/scripts/lib/rehearsal/baseline_builder.mjs +9 -4
- package/scripts/lib/rehearsal/baseline_preparation.mjs +201 -0
- package/scripts/lib/rehearsal/configuration.d.mts +14 -1
- package/scripts/lib/rehearsal/configuration.mjs +21 -10
- package/scripts/lib/rehearsal/diagnostics.mjs +7 -3
- package/scripts/lib/rehearsal/human_output.mjs +4 -0
- package/scripts/lib/rehearsal/plan.mjs +25 -2
- package/scripts/lib/rehearsal/policy_review.mjs +52 -0
- package/scripts/lib/rehearsal/sanitization_policy.mjs +110 -0
- package/scripts/lib/rehearsal/setup.mjs +347 -0
- package/scripts/operations/database/manage_rehearsal_database.mjs +12 -6
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +1060 -23
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,52 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## [0.1.0-beta.4] - 2026-10-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- A persistent guided session with polished terminal prompts, interactive policy review,
|
|
13
|
+
concise runtime receipts, expandable technical details, and real-PTY regression tests.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Guided menus now place the next recommended action first and automatically re-inspect
|
|
18
|
+
project state after every completed step while preserving plain and non-TTY modes.
|
|
19
|
+
|
|
20
|
+
## [0.1.0-beta.3] - 2026-10-01
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- A state-aware interactive home screen that guides setup, baseline preparation,
|
|
25
|
+
migration review, runtime management, verification, and cleanup without requiring
|
|
26
|
+
users to memorize commands.
|
|
27
|
+
- A safe `setup` workflow that previews and creates a dedicated local Supabase config,
|
|
28
|
+
chooses an available port block, updates protective ignore rules, and summarizes
|
|
29
|
+
readiness without overwriting project files.
|
|
30
|
+
- A schema-only `baseline prepare` workflow that generates a fail-closed policy draft
|
|
31
|
+
with explicit `REVIEW REQUIRED` decisions and never prints source row values.
|
|
32
|
+
- Interactive confirmation of the exact candidate migration set plus friendly progress
|
|
33
|
+
and timing while a rehearsal runs.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- Human output now recommends the next useful action, uses compact first-run readiness
|
|
38
|
+
summaries, respects `NO_COLOR` and `--plain`, and renders singular counts correctly.
|
|
39
|
+
- Bare `rehearsal` opens the guide only in a terminal and remains noninteractive and
|
|
40
|
+
script-safe when standard input or output is redirected.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Setup detects unsupported Node.js versions before writing, ignores ports occupied by
|
|
45
|
+
Docker or SSH forwarding, and rechecks its selected ports immediately before commit.
|
|
46
|
+
- Generated project identifiers are bounded to values accepted by the local runtime.
|
|
47
|
+
|
|
48
|
+
### Security
|
|
49
|
+
|
|
50
|
+
- Reviewed sanitization policy bytes are checksum-bound to the active baseline and are
|
|
51
|
+
revalidated during planning and runtime restore.
|
|
52
|
+
- Draft or incomplete sanitization policies cannot be activated as baselines.
|
|
53
|
+
|
|
8
54
|
## [0.1.0-beta.2] - 2026-09-15
|
|
9
55
|
|
|
10
56
|
### Fixed
|
|
@@ -72,7 +118,9 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
|
|
|
72
118
|
publication uses short-lived trusted OIDC, and every release tag must already exist on
|
|
73
119
|
protected `main`.
|
|
74
120
|
|
|
75
|
-
[Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.
|
|
121
|
+
[Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.4...HEAD
|
|
122
|
+
[0.1.0-beta.4]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.3...v0.1.0-beta.4
|
|
123
|
+
[0.1.0-beta.3]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.2...v0.1.0-beta.3
|
|
76
124
|
[0.1.0-beta.2]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.1...v0.1.0-beta.2
|
|
77
125
|
[0.1.0-beta.1]: https://github.com/Ddupasquier/rehearsal-db/releases/tag/v0.1.0-beta.1
|
|
78
126
|
[0.1.0-beta.0]: https://github.com/Ddupasquier/rehearsal-db/releases/tag/v0.1.0-beta.0
|
package/README.md
CHANGED
|
@@ -84,8 +84,13 @@ Contributors testing an unreleased change can use `npm link` or install the tarb
|
|
|
84
84
|
produced by `npm pack` from a local checkout. In a consuming project, the commands are:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
+
npx rehearsal
|
|
88
|
+
npx rehearsal setup
|
|
89
|
+
npx rehearsal setup --write
|
|
87
90
|
npx rehearsal init
|
|
88
91
|
npx rehearsal init --write
|
|
92
|
+
npx rehearsal baseline prepare --records=<safe.ndjson> --ledger=<ledger.json>
|
|
93
|
+
npx rehearsal baseline prepare --records=<safe.ndjson> --ledger=<ledger.json> --write
|
|
89
94
|
npx rehearsal baseline create --records=<safe.ndjson> --ledger=<ledger.json>
|
|
90
95
|
npx rehearsal doctor
|
|
91
96
|
npx rehearsal explain
|
|
@@ -102,14 +107,34 @@ npx rehearsal stop
|
|
|
102
107
|
npx rehearsal discard
|
|
103
108
|
```
|
|
104
109
|
|
|
110
|
+
Running `npx rehearsal` in a terminal opens a state-aware guide that shows completed
|
|
111
|
+
setup steps and recommends available actions. The guide stays open after each action,
|
|
112
|
+
re-inspects the project, and advances to the next useful step. It includes an interactive
|
|
113
|
+
column-by-column policy reviewer, concise completion receipts, and optional technical
|
|
114
|
+
details. The explicit commands remain the stable interface for automation and CI.
|
|
115
|
+
|
|
116
|
+
`setup` previews a conservative first-run scaffold: the Rehearsal configuration, a
|
|
117
|
+
dedicated local-only Supabase configuration on an available port block, and protective
|
|
118
|
+
`.gitignore` entries. It writes only with `--write`, never overwrites project files, and
|
|
119
|
+
does not copy enabled external providers from the application's Supabase configuration.
|
|
120
|
+
After writing, it runs the same readiness checks as `doctor` and shows the remaining
|
|
121
|
+
project-owned inputs. Use `init` when you want to create only the configuration file
|
|
122
|
+
manually.
|
|
123
|
+
|
|
124
|
+
`baseline prepare` inspects only the shape of explicit local synthetic records and writes
|
|
125
|
+
a fail-closed sanitization-policy draft. Every column remains `REVIEW REQUIRED` until a
|
|
126
|
+
human classifies its sanitization action, generated/identity behavior, and foreign key.
|
|
127
|
+
Drafts cannot be activated, and a reviewed policy is checksum-bound to its baseline.
|
|
128
|
+
|
|
105
129
|
`init` previews a type-aware ESM `rehearsal.config.mjs`; it writes only with `--write`
|
|
106
130
|
and never overwrites an existing file. The explicit `.mjs` extension makes the generated
|
|
107
131
|
configuration executable in both CommonJS and ESM projects. Review all detected values.
|
|
108
132
|
Rehearsal intentionally does not detect, copy, or enable a hosted project.
|
|
109
133
|
|
|
110
134
|
`doctor` must end with `READY` before execution. `explain` and `run --dry-run` use the
|
|
111
|
-
same immutable planner and perform no state-changing operations.
|
|
112
|
-
|
|
135
|
+
same immutable planner and perform no state-changing operations. In a terminal,
|
|
136
|
+
`rehearsal run` displays and confirms the exact candidate set before touching the local
|
|
137
|
+
runtime. Automation supplies the digest explicitly:
|
|
113
138
|
|
|
114
139
|
```bash
|
|
115
140
|
npx rehearsal run --confirm-candidates=<sha256>
|
package/docs/commands.md
CHANGED
|
@@ -6,8 +6,13 @@ values or credentials.
|
|
|
6
6
|
|
|
7
7
|
| Command | Mutates local state | Purpose |
|
|
8
8
|
| ------------------------------------------------------------ | ------------------- | --------------------------------------------------------- |
|
|
9
|
+
| `rehearsal` or `rehearsal guide` | No by default | Open the guided, state-aware interactive home screen. |
|
|
10
|
+
| `rehearsal setup` | No | Preview safe config, local runtime, and ignore files. |
|
|
11
|
+
| `rehearsal setup --write` | Project files | Create the previewed first-run scaffolding. |
|
|
9
12
|
| `rehearsal init` | No | Preview safe starter configuration. |
|
|
10
13
|
| `rehearsal init --write` | Config only | Create config without overwriting. |
|
|
14
|
+
| `rehearsal baseline prepare --records= --ledger=` | No | Preview a fail-closed policy draft from local shape. |
|
|
15
|
+
| `rehearsal baseline prepare --records= --ledger= --write` | Policy only | Write the draft without exposing row values. |
|
|
11
16
|
| `rehearsal baseline create --records=<path> --ledger=<path>` | Artifact only | Activate a baseline from explicit safe local inputs. |
|
|
12
17
|
| `rehearsal doctor` | No | Check dependencies, inputs, and safety barriers. |
|
|
13
18
|
| `rehearsal explain` | No | Print the immutable execution plan. |
|
|
@@ -24,12 +29,38 @@ values or credentials.
|
|
|
24
29
|
| `rehearsal stop` | Runtime only | Stop this project's local services. |
|
|
25
30
|
| `rehearsal discard` | Yes, local only | Remove only this project's disposable runtime and volume. |
|
|
26
31
|
|
|
27
|
-
`run`
|
|
28
|
-
|
|
32
|
+
In an interactive terminal, `run` and `migrate` display the candidate files and ask for
|
|
33
|
+
confirmation before touching the runtime. In automation, they require the digest from
|
|
34
|
+
the current candidate set. Any added, removed, reordered, or edited migration changes
|
|
35
|
+
the digest and invalidates either form of confirmation.
|
|
29
36
|
|
|
30
37
|
`baseline create` never extracts data. The NDJSON and migration-ledger files must already
|
|
31
38
|
exist inside the project and be safe to retain. Add `--assets=<manifest.json>` to include
|
|
32
39
|
bounded local Storage bytes; every manifest `file` must also remain inside the project.
|
|
40
|
+
The guided home screen can ask for these paths so they do not need to be supplied as
|
|
41
|
+
flags.
|
|
42
|
+
|
|
43
|
+
The guide is a persistent session: after setup, policy review, baseline creation, or a
|
|
44
|
+
runtime action it re-reads project state and offers the next relevant action. Generated
|
|
45
|
+
policy drafts can be completed interactively without editing JSON. Each column still
|
|
46
|
+
requires explicit action, generated, identity, and foreign-key decisions; the guide does
|
|
47
|
+
not silently infer them.
|
|
48
|
+
|
|
49
|
+
`baseline prepare` reads only table and column names from the NDJSON records; row values
|
|
50
|
+
are never included in its result. Its generated policy deliberately marks every column
|
|
51
|
+
decision `REVIEW REQUIRED` and cannot be activated until a human completes the metadata
|
|
52
|
+
and removes the `draft` marker. Rehearsal binds the reviewed policy checksum to the
|
|
53
|
+
baseline and rejects later policy changes during planning and restore.
|
|
33
54
|
|
|
34
55
|
Automation should use `--json` and inspect both exit status and the versioned envelope.
|
|
35
56
|
Exit-code meanings are documented in the root README. Scripts must not parse human text.
|
|
57
|
+
When standard input or output is not an interactive terminal, bare `rehearsal` prints
|
|
58
|
+
help instead of prompting. Use `--plain` to disable decorative terminal styling.
|
|
59
|
+
`NO_COLOR` also selects the plain numbered interface. Both interactive modes preserve
|
|
60
|
+
the same safety decisions and cancellation behavior.
|
|
61
|
+
|
|
62
|
+
`setup` chooses an available local port block and generates a conservative Supabase
|
|
63
|
+
configuration with hosted access and optional networked services disabled. It does not
|
|
64
|
+
overwrite an existing Rehearsal config, dedicated Supabase config, or concurrently
|
|
65
|
+
changed `.gitignore`. After writing, it includes a Doctor readiness summary. `init`
|
|
66
|
+
remains available for config-only/manual onboarding.
|
package/docs/getting-started.md
CHANGED
|
@@ -34,18 +34,31 @@ its labeled local runtime. It does not need a hosted Supabase project or credent
|
|
|
34
34
|
|
|
35
35
|
## 1. Install and initialize
|
|
36
36
|
|
|
37
|
+
Confirm this shell is running the supported Node.js major before installing:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
node --version # v24.x
|
|
41
|
+
```
|
|
42
|
+
|
|
37
43
|
```bash
|
|
38
44
|
npm install --save-dev @rehearsal-db/core@beta
|
|
39
|
-
npx rehearsal
|
|
45
|
+
npx rehearsal
|
|
40
46
|
```
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
Choose **Set up Rehearsal** in the guide. It previews a versioned configuration, a
|
|
49
|
+
dedicated local-only Supabase configuration using available ports, and protective
|
|
50
|
+
`.gitignore` entries before asking permission to write. The guide remains open afterward
|
|
51
|
+
and recommends the next incomplete stage.
|
|
52
|
+
|
|
53
|
+
The same flow is available noninteractively as an explicit preview and write:
|
|
43
54
|
|
|
44
55
|
```bash
|
|
45
|
-
npx rehearsal
|
|
56
|
+
npx rehearsal setup
|
|
57
|
+
npx rehearsal setup --write
|
|
46
58
|
```
|
|
47
59
|
|
|
48
|
-
Rehearsal never overwrites an existing configuration.
|
|
60
|
+
Rehearsal never overwrites an existing configuration. For config-only/manual setup, use
|
|
61
|
+
`npx rehearsal init` followed by `npx rehearsal init --write`.
|
|
49
62
|
|
|
50
63
|
The generated configuration is intentionally incomplete until you review its ports,
|
|
51
64
|
project ID, application commands, and project-owned input paths. Do not run `doctor`
|
|
@@ -53,14 +66,14 @@ until the next section's files exist.
|
|
|
53
66
|
|
|
54
67
|
## 2. Add the project-owned inputs
|
|
55
68
|
|
|
56
|
-
|
|
69
|
+
Review or create the project-owned inputs named by `rehearsal.config.mjs`:
|
|
57
70
|
|
|
58
|
-
-
|
|
71
|
+
- the dedicated local Supabase `config.toml` (`setup` creates a conservative one);
|
|
59
72
|
- a sanitization policy describing every exported field;
|
|
60
73
|
- an active baseline below `.rehearsal/`;
|
|
61
74
|
- an application proof command that exits nonzero when the restored app is wrong.
|
|
62
75
|
|
|
63
|
-
|
|
76
|
+
If you used config-only `init`, also create the dedicated Supabase config manually:
|
|
64
77
|
|
|
65
78
|
```bash
|
|
66
79
|
mkdir -p infrastructure/rehearsal/supabase infrastructure/rehearsal rehearsal
|
|
@@ -79,6 +92,27 @@ Start with synthetic rows shaped like your schema. Do not start onboarding with
|
|
|
79
92
|
production data. The following SQL, policy, row, and ledger form one matched example;
|
|
80
93
|
do not mix them with differently shaped snippets.
|
|
81
94
|
|
|
95
|
+
If you already have the safe NDJSON rows and migration ledger, Rehearsal can enumerate
|
|
96
|
+
their table and column shape into a fail-closed policy draft without printing values:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npx rehearsal baseline prepare \
|
|
100
|
+
--records=rehearsal/synthetic-data.ndjson \
|
|
101
|
+
--ledger=rehearsal/migration-ledger.json
|
|
102
|
+
npx rehearsal baseline prepare \
|
|
103
|
+
--records=rehearsal/synthetic-data.ndjson \
|
|
104
|
+
--ledger=rehearsal/migration-ledger.json \
|
|
105
|
+
--write
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
After creating the draft, choose **Review the script** in the guide. Rehearsal walks each
|
|
109
|
+
column through sanitization action, generated status, identity status, and optional
|
|
110
|
+
foreign-key metadata. Every answer is explicit, the complete policy is validated before
|
|
111
|
+
writing, and the original draft is replaced only if it did not change during review.
|
|
112
|
+
|
|
113
|
+
For noninteractive workflows, review every `REVIEW REQUIRED` field directly and remove
|
|
114
|
+
`"draft": true` only after that review. Rehearsal refuses to activate a draft.
|
|
115
|
+
|
|
82
116
|
Historical migration `supabase/migrations/20260101000000_create_widgets.sql`:
|
|
83
117
|
|
|
84
118
|
```sql
|
package/docs/releasing.md
CHANGED
|
@@ -37,7 +37,7 @@ publication must change and prove the workflow before narrowing that permission.
|
|
|
37
37
|
onboarding. Correct and retest the first confusing, missing, or wrong instruction.
|
|
38
38
|
5. Change `private` to `false` only in the reviewed release change.
|
|
39
39
|
6. Record the exact tarball filename, SHA-1, SHA-256, allowlisted files, unpacked size,
|
|
40
|
-
executable,
|
|
40
|
+
executable, declared runtime dependency inventory, and dependency audit result.
|
|
41
41
|
7. Obtain explicit publication authorization for that exact version and artifact.
|
|
42
42
|
8. Merge the approved release commit through protected `main` and create the exact
|
|
43
43
|
`v<package-version>` tag and GitHub release.
|
package/docs/sanitization.md
CHANGED
|
@@ -13,6 +13,18 @@ Every exported field should receive one action:
|
|
|
13
13
|
|
|
14
14
|
Fail if a new column is unclassified. Do not default unknown fields to `KEEP`.
|
|
15
15
|
|
|
16
|
+
For runtime restore, each included column must also classify whether it is generated,
|
|
17
|
+
whether it is an identity column, and its foreign-key target or explicit absence. Run
|
|
18
|
+
`rehearsal baseline prepare` to create a shape-only draft from synthetic NDJSON. The
|
|
19
|
+
draft uses `REVIEW REQUIRED` placeholders and cannot be activated until they are
|
|
20
|
+
replaced and the `draft` marker is removed.
|
|
21
|
+
|
|
22
|
+
In an interactive terminal, the guided **Review the script** action completes these
|
|
23
|
+
decisions one column at a time. Replacement-oriented actions are shown first, retaining
|
|
24
|
+
a value is explicitly labeled as sensitive, and no choice is silently inferred. The
|
|
25
|
+
reviewer validates the completed policy and refuses to overwrite a draft changed during
|
|
26
|
+
the session.
|
|
27
|
+
|
|
16
28
|
The package exports `validateSanitizationCoverage` and
|
|
17
29
|
`applySanitizationAction` as generic primitives:
|
|
18
30
|
|
|
@@ -67,3 +79,7 @@ Before activation, verify:
|
|
|
67
79
|
|
|
68
80
|
Completeness is not correctness. Human review of the policy and export boundary remains
|
|
69
81
|
mandatory before any real source is introduced.
|
|
82
|
+
|
|
83
|
+
The exact reviewed policy bytes are checksum-bound to the active baseline. Planning,
|
|
84
|
+
candidate inspection, and runtime restore refuse to continue if that file later changes;
|
|
85
|
+
build a new reviewed baseline instead of editing the active policy in place.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Troubleshooting
|
|
2
2
|
|
|
3
|
+
## Setup says Node.js 24 is required
|
|
4
|
+
|
|
5
|
+
Rehearsal intentionally supports one maintained Node.js major in its first beta. Switch
|
|
6
|
+
the current shell before installing or running it. With nvm:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
nvm install 24
|
|
10
|
+
nvm use 24
|
|
11
|
+
node --version
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then reinstall Rehearsal in the consuming project. The guided CLI checks this before it
|
|
15
|
+
writes setup files.
|
|
16
|
+
|
|
3
17
|
## Doctor says Docker is unavailable
|
|
4
18
|
|
|
5
19
|
Start Docker Desktop or Colima, confirm `docker info`, then rerun `rehearsal doctor`.
|
|
@@ -7,8 +21,10 @@ Restarting the computer is rarely necessary.
|
|
|
7
21
|
|
|
8
22
|
## A port is already in use
|
|
9
23
|
|
|
10
|
-
|
|
11
|
-
|
|
24
|
+
Rerun `rehearsal setup` to select a different available block. Setup checks both active
|
|
25
|
+
listeners and whether every selected port can be bound, then checks again before writing.
|
|
26
|
+
For an existing configuration, choose unique non-privileged ports and mirror them in the
|
|
27
|
+
dedicated Supabase config. Do not stop an unrelated database to make defaults fit.
|
|
12
28
|
|
|
13
29
|
## Baseline checksum mismatch
|
|
14
30
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rehearsal-db/core",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Safely rehearse Supabase migrations against sanitized, production-shaped PostgreSQL data.",
|
|
6
6
|
"repository": {
|
|
@@ -69,8 +69,11 @@
|
|
|
69
69
|
"database"
|
|
70
70
|
],
|
|
71
71
|
"license": "MIT",
|
|
72
|
-
"dependencies": {
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"@clack/prompts": "^1.8.1"
|
|
74
|
+
},
|
|
73
75
|
"devDependencies": {
|
|
76
|
+
"@lydell/node-pty": "^1.2.0-beta.15",
|
|
74
77
|
"prettier": "^3.9.6",
|
|
75
78
|
"vitest": "^4.1.10"
|
|
76
79
|
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createMigrationReplayReceipt,
|
|
20
20
|
readMigrationSourceBundle,
|
|
21
21
|
} from "./migration_history.mjs";
|
|
22
|
+
import { validateRuntimeSanitizationPolicy } from "./sanitization_policy.mjs";
|
|
22
23
|
|
|
23
24
|
const resolveProjectInput = (projectRoot, value, label) => {
|
|
24
25
|
if (typeof value !== "string" || value.trim() === "") {
|
|
@@ -103,10 +104,9 @@ export const createSyntheticBaselineFromFiles = async ({
|
|
|
103
104
|
})),
|
|
104
105
|
)
|
|
105
106
|
: [];
|
|
106
|
-
const policy =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
107
|
+
const policy = validateRuntimeSanitizationPolicy(
|
|
108
|
+
JSON.parse(policyBytes.toString("utf8")),
|
|
109
|
+
);
|
|
110
110
|
const expectedTables = policy.tables
|
|
111
111
|
.filter((table) => table.sourceRows !== "EXCLUDE")
|
|
112
112
|
.map((table) => table.name);
|
|
@@ -117,6 +117,11 @@ export const createSyntheticBaselineFromFiles = async ({
|
|
|
117
117
|
JSON.parse(ledgerBytes.toString("utf8")),
|
|
118
118
|
"Synthetic migration ledger",
|
|
119
119
|
);
|
|
120
|
+
if (policy.migrationCutoff !== sourceMigrationHistory.at(-1).version) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`The sanitization policy cutoff ${policy.migrationCutoff} does not match migration ledger cutoff ${sourceMigrationHistory.at(-1).version}.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
120
125
|
const migrationFiles = await readMigrationSourceBundle({
|
|
121
126
|
directory: new URL(
|
|
122
127
|
"./",
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Inspect explicit local synthetic records and migration evidence, then
|
|
3
|
+
* create a fail-closed sanitization-policy draft for human review.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createReadStream } from "node:fs";
|
|
7
|
+
import { access, mkdir, open, readFile } from "node:fs/promises";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { loadRehearsalConfig } from "./configuration.mjs";
|
|
11
|
+
import { buildMigrationLedgerInventory } from "./migration_history.mjs";
|
|
12
|
+
|
|
13
|
+
const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
14
|
+
|
|
15
|
+
const resolveProjectInput = (projectRoot, value, label) => {
|
|
16
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
17
|
+
throw new Error(`${label} is required.`);
|
|
18
|
+
}
|
|
19
|
+
const path = resolve(projectRoot, value);
|
|
20
|
+
const owned = relative(projectRoot, path);
|
|
21
|
+
if (
|
|
22
|
+
owned === "" ||
|
|
23
|
+
owned === ".." ||
|
|
24
|
+
owned.startsWith(`..${sep}`) ||
|
|
25
|
+
isAbsolute(owned)
|
|
26
|
+
) {
|
|
27
|
+
throw new Error(`${label} must be a file inside the project root.`);
|
|
28
|
+
}
|
|
29
|
+
return path;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const pathExists = (path) =>
|
|
33
|
+
access(path)
|
|
34
|
+
.then(() => true)
|
|
35
|
+
.catch((error) => {
|
|
36
|
+
if (error?.code === "ENOENT") return false;
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const inspectSyntheticRecords = async (path) => {
|
|
41
|
+
const tables = new Map();
|
|
42
|
+
let rowCount = 0;
|
|
43
|
+
const lines = createInterface({
|
|
44
|
+
input: createReadStream(path, { encoding: "utf8" }),
|
|
45
|
+
crlfDelay: Infinity,
|
|
46
|
+
});
|
|
47
|
+
for await (const line of lines) {
|
|
48
|
+
if (!line.trim()) continue;
|
|
49
|
+
let record;
|
|
50
|
+
try {
|
|
51
|
+
record = JSON.parse(line);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error("Synthetic baseline input contains invalid NDJSON.", {
|
|
54
|
+
cause: error,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
!record ||
|
|
59
|
+
!identifierPattern.test(record.table ?? "") ||
|
|
60
|
+
!record.row ||
|
|
61
|
+
typeof record.row !== "object" ||
|
|
62
|
+
Array.isArray(record.row)
|
|
63
|
+
) {
|
|
64
|
+
throw new Error("A synthetic baseline record has an invalid shape.");
|
|
65
|
+
}
|
|
66
|
+
const columns = Object.keys(record.row);
|
|
67
|
+
if (
|
|
68
|
+
columns.length === 0 ||
|
|
69
|
+
columns.some((column) => !identifierPattern.test(column))
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Synthetic baseline table ${record.table} has invalid or empty columns.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const table = tables.get(record.table) ?? {
|
|
76
|
+
name: record.table,
|
|
77
|
+
rowCount: 0,
|
|
78
|
+
columns: new Set(),
|
|
79
|
+
};
|
|
80
|
+
table.rowCount += 1;
|
|
81
|
+
for (const column of columns) table.columns.add(column);
|
|
82
|
+
tables.set(record.table, table);
|
|
83
|
+
rowCount += 1;
|
|
84
|
+
}
|
|
85
|
+
if (rowCount === 0) {
|
|
86
|
+
throw new Error("Synthetic baseline input contains no records.");
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
rowCount,
|
|
90
|
+
tables: [...tables.values()]
|
|
91
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
92
|
+
.map((table) => ({
|
|
93
|
+
name: table.name,
|
|
94
|
+
rowCount: table.rowCount,
|
|
95
|
+
columns: [...table.columns].sort(),
|
|
96
|
+
})),
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const renderPolicyDraft = ({ migrationCutoff, tables }) =>
|
|
101
|
+
`${JSON.stringify(
|
|
102
|
+
{
|
|
103
|
+
policyVersion: 1,
|
|
104
|
+
draft: true,
|
|
105
|
+
migrationCutoff,
|
|
106
|
+
tables: tables.map((table) => ({
|
|
107
|
+
name: table.name,
|
|
108
|
+
group: "synthetic",
|
|
109
|
+
sourceRows: "STREAM AND SANITIZE",
|
|
110
|
+
columns: table.columns.map((name) => ({
|
|
111
|
+
name,
|
|
112
|
+
action: "REVIEW REQUIRED",
|
|
113
|
+
generated: "REVIEW REQUIRED",
|
|
114
|
+
identity: "REVIEW REQUIRED",
|
|
115
|
+
foreignKey: "REVIEW REQUIRED",
|
|
116
|
+
})),
|
|
117
|
+
})),
|
|
118
|
+
},
|
|
119
|
+
null,
|
|
120
|
+
2,
|
|
121
|
+
)}\n`;
|
|
122
|
+
|
|
123
|
+
export const planBaselinePreparation = async ({
|
|
124
|
+
projectRoot = process.cwd(),
|
|
125
|
+
configPath,
|
|
126
|
+
recordsPath,
|
|
127
|
+
ledgerPath,
|
|
128
|
+
}) => {
|
|
129
|
+
const loaded = await loadRehearsalConfig({ projectRoot, configPath });
|
|
130
|
+
const records = resolveProjectInput(
|
|
131
|
+
loaded.projectRoot,
|
|
132
|
+
recordsPath,
|
|
133
|
+
"Synthetic records path",
|
|
134
|
+
);
|
|
135
|
+
const ledger = resolveProjectInput(
|
|
136
|
+
loaded.projectRoot,
|
|
137
|
+
ledgerPath,
|
|
138
|
+
"Migration ledger path",
|
|
139
|
+
);
|
|
140
|
+
const [inspection, ledgerRows] = await Promise.all([
|
|
141
|
+
inspectSyntheticRecords(records),
|
|
142
|
+
readFile(ledger, "utf8").then(JSON.parse),
|
|
143
|
+
]);
|
|
144
|
+
const migrationHistory = buildMigrationLedgerInventory(
|
|
145
|
+
ledgerRows,
|
|
146
|
+
"Synthetic migration ledger",
|
|
147
|
+
);
|
|
148
|
+
const destination = loaded.paths.sanitizationPolicy;
|
|
149
|
+
if (await pathExists(destination)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Rehearsal baseline preparation will not overwrite ${relative(loaded.projectRoot, destination)}.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const migrationCutoff = migrationHistory.at(-1).version;
|
|
155
|
+
return {
|
|
156
|
+
projectRoot: loaded.projectRoot,
|
|
157
|
+
destination,
|
|
158
|
+
destinationRelative: relative(loaded.projectRoot, destination),
|
|
159
|
+
recordsPath: relative(loaded.projectRoot, records),
|
|
160
|
+
ledgerPath: relative(loaded.projectRoot, ledger),
|
|
161
|
+
migrationCutoff,
|
|
162
|
+
migrationCount: migrationHistory.length,
|
|
163
|
+
rowCount: inspection.rowCount,
|
|
164
|
+
tables: inspection.tables,
|
|
165
|
+
content: renderPolicyDraft({
|
|
166
|
+
migrationCutoff,
|
|
167
|
+
tables: inspection.tables,
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const applyBaselinePreparation = async (plan) => {
|
|
173
|
+
if (await pathExists(plan.destination)) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Rehearsal baseline preparation will not overwrite ${plan.destinationRelative}.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
await mkdir(dirname(plan.destination), { recursive: true, mode: 0o700 });
|
|
179
|
+
const handle = await open(plan.destination, "wx", 0o600);
|
|
180
|
+
try {
|
|
181
|
+
await handle.writeFile(plan.content);
|
|
182
|
+
await handle.sync();
|
|
183
|
+
} finally {
|
|
184
|
+
await handle.close();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const summarizeBaselinePreparation = (plan, { mode }) => ({
|
|
189
|
+
mode,
|
|
190
|
+
destination: plan.destinationRelative,
|
|
191
|
+
recordsPath: plan.recordsPath,
|
|
192
|
+
ledgerPath: plan.ledgerPath,
|
|
193
|
+
migrationCutoff: plan.migrationCutoff,
|
|
194
|
+
migrationCount: plan.migrationCount,
|
|
195
|
+
rowCount: plan.rowCount,
|
|
196
|
+
tables: plan.tables,
|
|
197
|
+
nextAction:
|
|
198
|
+
mode === "written"
|
|
199
|
+
? `Review every REVIEW REQUIRED decision in ${plan.destinationRelative}, remove draft only after review, then create the baseline.`
|
|
200
|
+
: "Review this schema-only summary, then rerun with --write to create the draft.",
|
|
201
|
+
});
|
|
@@ -51,7 +51,13 @@ export declare const loadRehearsalConfig: (options?: {
|
|
|
51
51
|
export declare const inspectDetectedProject: (options?: {
|
|
52
52
|
projectRoot?: string;
|
|
53
53
|
}) => Promise<unknown>;
|
|
54
|
-
export declare const renderDetectedConfig: (
|
|
54
|
+
export declare const renderDetectedConfig: (
|
|
55
|
+
detected: unknown,
|
|
56
|
+
options?: {
|
|
57
|
+
applicationUrl?: string;
|
|
58
|
+
ports?: { api: number; database: number; studio: number };
|
|
59
|
+
},
|
|
60
|
+
) => string;
|
|
55
61
|
export declare const SANITIZATION_ACTIONS: Readonly<{
|
|
56
62
|
KEEP: "KEEP";
|
|
57
63
|
PSEUDONYMIZE: "PSEUDONYMIZE";
|
|
@@ -75,6 +81,13 @@ export declare const validateSanitizationCoverage: (options: {
|
|
|
75
81
|
columnCount: number;
|
|
76
82
|
tables: ReadonlyArray<Record<string, unknown>>;
|
|
77
83
|
}>;
|
|
84
|
+
export declare const validateRuntimeSanitizationPolicy: (
|
|
85
|
+
policy: unknown,
|
|
86
|
+
) => unknown;
|
|
87
|
+
export declare const readBoundRuntimeSanitizationPolicy: (options: {
|
|
88
|
+
bytes: Uint8Array | string;
|
|
89
|
+
expectedSha256: string;
|
|
90
|
+
}) => unknown;
|
|
78
91
|
export declare const applySanitizationAction: (options: {
|
|
79
92
|
action: string;
|
|
80
93
|
value: unknown;
|
|
@@ -13,7 +13,9 @@ export {
|
|
|
13
13
|
SANITIZATION_ACTIONS,
|
|
14
14
|
applySanitizationAction,
|
|
15
15
|
normalizeSanitizationAction,
|
|
16
|
+
readBoundRuntimeSanitizationPolicy,
|
|
16
17
|
validateSanitizationCoverage,
|
|
18
|
+
validateRuntimeSanitizationPolicy,
|
|
17
19
|
} from "./sanitization_policy.mjs";
|
|
18
20
|
|
|
19
21
|
export const REHEARSAL_CONFIG_VERSION = 1;
|
|
@@ -494,12 +496,15 @@ export const inspectDetectedProject = async ({
|
|
|
494
496
|
? "yarn"
|
|
495
497
|
: "npm";
|
|
496
498
|
const scripts = packageJson.scripts ?? {};
|
|
499
|
+
const normalizedProjectName = String(packageJson.name ?? basename(root))
|
|
500
|
+
.toLowerCase()
|
|
501
|
+
.replace(/[^a-z0-9-]+/gu, "-")
|
|
502
|
+
.replace(/^-|-$/gu, "");
|
|
503
|
+
const projectName =
|
|
504
|
+
normalizedProjectName.slice(0, 52).replace(/-$/u, "") ||
|
|
505
|
+
"rehearsal-project";
|
|
497
506
|
return {
|
|
498
|
-
projectName
|
|
499
|
-
String(packageJson.name ?? basename(root))
|
|
500
|
-
.toLowerCase()
|
|
501
|
-
.replace(/[^a-z0-9-]+/gu, "-")
|
|
502
|
-
.replace(/^-|-$/gu, "") || "rehearsal-project",
|
|
507
|
+
projectName,
|
|
503
508
|
packageManager,
|
|
504
509
|
hasSupabaseConfig: await hasPath("supabase/config.toml"),
|
|
505
510
|
hasMigrations: await hasPath("supabase/migrations"),
|
|
@@ -515,7 +520,13 @@ export const inspectDetectedProject = async ({
|
|
|
515
520
|
};
|
|
516
521
|
};
|
|
517
522
|
|
|
518
|
-
export const renderDetectedConfig = (
|
|
523
|
+
export const renderDetectedConfig = (
|
|
524
|
+
detected,
|
|
525
|
+
{
|
|
526
|
+
applicationUrl = "http://localhost:5175",
|
|
527
|
+
ports = { api: 58321, database: 58322, studio: 58323 },
|
|
528
|
+
} = {},
|
|
529
|
+
) => `// @ts-check
|
|
519
530
|
import { defineRehearsalConfig } from "@rehearsal-db/core";
|
|
520
531
|
|
|
521
532
|
export default defineRehearsalConfig({
|
|
@@ -537,11 +548,11 @@ export default defineRehearsalConfig({
|
|
|
537
548
|
environmentFile: ".rehearsal/runtime.env",
|
|
538
549
|
},
|
|
539
550
|
runtime: {
|
|
540
|
-
applicationUrl:
|
|
551
|
+
applicationUrl: ${JSON.stringify(applicationUrl)},
|
|
541
552
|
projectId: "${detected.projectName}-rehearsal",
|
|
542
|
-
apiPort:
|
|
543
|
-
databasePort:
|
|
544
|
-
studioPort:
|
|
553
|
+
apiPort: ${ports.api},
|
|
554
|
+
databasePort: ${ports.database},
|
|
555
|
+
studioPort: ${ports.studio},
|
|
545
556
|
},
|
|
546
557
|
safety: {
|
|
547
558
|
allowedHosts: ["127.0.0.1", "::1", "localhost"],
|