@runeditrun/rer 0.1.0 → 0.2.0
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/README.md +115 -2
- package/cli/README.md +28 -3
- package/cli/contract.mjs +4 -4
- package/cli/dev-identity.mjs +2 -1
- package/cli/door-mode.mjs +134 -0
- package/cli/door.mjs +263 -0
- package/cli/hosted-output.mjs +15 -5
- package/cli/hydration/browser.mjs +91 -0
- package/cli/hydration/cases.mjs +106 -0
- package/cli/hydration/check.mjs +105 -0
- package/cli/hydration/probe.mjs +164 -0
- package/cli/hydration/scripts.mjs +39 -0
- package/cli/hydration/verdict.mjs +85 -0
- package/cli/inspect.mjs +56 -9
- package/cli/lifecycle.mjs +36 -12
- package/cli/local.mjs +9 -3
- package/cli/main.mjs +2 -0
- package/cli/managed-server.mjs +52 -18
- package/cli/package-calls.mjs +81 -0
- package/cli/package-mode.mjs +46 -0
- package/cli/parse.mjs +14 -4
- package/cli/pnpm-runtime.mjs +96 -0
- package/cli/portability-worker.mjs +2 -2
- package/cli/runtime-front-door.mjs +4 -2
- package/cli/server-proxy.mjs +69 -5
- package/cli/server-registry.mjs +19 -11
- package/cli/settings.mjs +14 -0
- package/cli/site-check.mjs +96 -0
- package/cli/site-preview.mjs +53 -0
- package/cli/site-render.mjs +77 -0
- package/cli/site.mjs +109 -0
- package/global/CHANGELOG.md +63 -0
- package/global/README.md +115 -2
- package/global/checkout.mjs +24 -10
- package/global/cli/install.mjs +6 -2
- package/global/delegate.mjs +9 -7
- package/global/door/command.mjs +254 -0
- package/global/door/control.mjs +65 -0
- package/global/door/cookies.mjs +41 -0
- package/global/door/pages.mjs +49 -0
- package/global/door/policy.mjs +49 -0
- package/global/door/routes.mjs +45 -0
- package/global/door/server.mjs +255 -0
- package/global/door/sites.mjs +192 -0
- package/global/launch/dev.mjs +25 -23
- package/global/launch/door.mjs +36 -0
- package/global/launch/facts.mjs +3 -2
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +41 -6
- package/global/launch/render.mjs +6 -1
- package/global/launch/state.mjs +10 -1
- package/global/launch/steps.mjs +24 -10
- package/global/launch/stop.mjs +89 -0
- package/global/launch/worker.mjs +30 -11
- package/global/list.mjs +6 -3
- package/global/main.mjs +37 -3
- package/global/version.mjs +8 -8
- package/modules/dependencies/protocol.mjs +2 -1
- package/modules/deploy-kit/README.md +34 -1
- package/modules/deploy-kit/artifact.mjs +10 -0
- package/modules/deploy-kit/owner.mjs +5 -4
- package/modules/deploy-kit/prepare.mjs +3 -2
- package/modules/deploy-kit/provider.mjs +6 -3
- package/modules/deploy-kit/site.mjs +135 -0
- package/modules/erasure/CONTRACT.md +6 -3
- package/modules/erasure/README.md +8 -1
- package/modules/erasure/audit.mjs +2 -2
- package/modules/erasure/cascade.mjs +52 -11
- package/modules/erasure/order.mjs +29 -22
- package/modules/erasure/plan.mjs +4 -2
- package/modules/erasure/queue.mjs +41 -0
- package/modules/erasure/scope.mjs +3 -3
- package/modules/erasure/verification.mjs +4 -2
- package/modules/mail/README.md +1 -1
- package/modules/mail/index.mjs +1 -1
- package/modules/mail/src/core/errors.mjs +9 -0
- package/modules/mail/src/development/index.d.mts +6 -0
- package/modules/mail/src/development/index.mjs +6 -3
- package/modules/mail/src/development/viewer-html.mjs +3 -3
- package/modules/mail/src/development/viewer.mjs +8 -1
- package/modules/operator-auth/dev/index.mjs +15 -5
- package/modules/operator-auth/http.mjs +2 -3
- package/modules/operator-auth/loopback.mjs +17 -0
- package/modules/owner-claim/verify.mjs +2 -1
- package/modules/portability/README.md +8 -0
- package/modules/portability/hash.mjs +6 -2
- package/modules/portability/import.mjs +1 -1
- package/modules/portability/legacy-files.mjs +4 -4
- package/modules/portability/legacy-safety.mjs +51 -10
- package/modules/portability/migration-history.mjs +15 -0
- package/modules/release/README.md +5 -3
- package/modules/release/produce.mjs +5 -2
- package/modules/release/source.mjs +15 -4
- package/onboarding/adapter-auth.mjs +4 -3
- package/onboarding/adapter-settings.mjs +21 -5
- package/onboarding/adapter.mjs +2 -1
- package/onboarding/app-fetch.mjs +79 -0
- package/onboarding/archive-output.d.mts +4 -2
- package/onboarding/archive-output.mjs +22 -10
- package/onboarding/dependency-approval.mjs +4 -3
- package/onboarding/local/environment.mjs +7 -2
- package/onboarding/operator-install.mjs +8 -2
- package/onboarding/operator-prerequisites.mjs +5 -2
- package/onboarding/queued-entry.mjs +9 -5
- package/operations/operations.mjs +11 -2
- package/package.json +75 -1
- package/scripts/publish-check/github_refs.py +28 -2
- package/scripts/publish-check/public_hygiene.py +35 -4
- package/scripts/publish-check/publication-config.json +14 -1
package/global/main.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { dispatcher, locate, unsupportedMessage } from './checkout.mjs';
|
|
1
|
+
import { dispatcher, locate, pinnedPackage, unsupportedMessage } from './checkout.mjs';
|
|
2
2
|
import { delegate } from './delegate.mjs';
|
|
3
3
|
import { localDefaults } from './launch/local-defaults.mjs';
|
|
4
|
+
import { packageName as pinnedName } from './package-info.mjs';
|
|
4
5
|
|
|
5
6
|
const help = `Usage: rer VERB [FLAGS]
|
|
6
7
|
|
|
7
8
|
Inside a project (the nearest OPERATIONS.json at or above this folder, or --cwd DIR),
|
|
8
|
-
every verb runs that checkout's own pinned rer
|
|
9
|
+
every verb runs that checkout's own pinned rer: the @runeditrun/rer version its package.json
|
|
10
|
+
pins (installed first by rer install or rer dev), else its vendored scripts/rer.mjs.
|
|
9
11
|
|
|
10
12
|
rer dev Launch a first-party checkout with no setup steps: installs dependencies,
|
|
11
13
|
sets up a local owner and a free port when needed, starts the app and prints
|
|
@@ -14,6 +16,8 @@ every verb runs that checkout's own pinned rer, exactly as node scripts/rer.mjs
|
|
|
14
16
|
to 10 minutes on a first launch, or rerun it to reattach. rer dev --stop stops it.
|
|
15
17
|
rer list [DIR] The checkouts in DIR (default: here), newest first, with whether rer dev can
|
|
16
18
|
launch them, whether they are set up, and their running URL. --json for data.
|
|
19
|
+
rer door ... The opt-in front door: every checkout at http://NAME.localhost:PORT, started
|
|
20
|
+
on its first visit and stopped when idle. rer door --help for its verbs.
|
|
17
21
|
rer cli status This rer: version, contents digest, install channel, launcher and other rer
|
|
18
22
|
commands on PATH. --json for data.
|
|
19
23
|
rer cli install Install this package for the current user (~/.local/bin/rer).
|
|
@@ -21,11 +25,17 @@ every verb runs that checkout's own pinned rer, exactly as node scripts/rer.mjs
|
|
|
21
25
|
rer update Move to the latest published rer (--version X for an exact one), the way it
|
|
22
26
|
was installed: npx and install.sh installs switch atomically, npm installs run npm.
|
|
23
27
|
rer --version Print this rer's version; inside a checkout, also the version the checkout pins.
|
|
28
|
+
rer queue NAME -- COMMAND [ARGS]
|
|
29
|
+
Run COMMAND once this computer's job queue admits it (heavy package scripts
|
|
30
|
+
of a checkout that pins the rer package use it).
|
|
24
31
|
|
|
25
32
|
Checkout verbs: install, setup, dev, preview, build, verify, reset, deploy, export, import,
|
|
26
33
|
outbox, doctor, describe, check, release. Run rer VERB --help in a checkout for its flags.
|
|
27
34
|
`;
|
|
28
35
|
|
|
36
|
+
// The verbs that install an uninstalled pinned package before they run.
|
|
37
|
+
const installsFirst = new Set(['install', 'check', 'describe']);
|
|
38
|
+
|
|
29
39
|
export async function main(argv) {
|
|
30
40
|
const [verb] = argv;
|
|
31
41
|
if (verb === '--version' || verb === '-v') return (await import('./version.mjs')).versionCommand(argv);
|
|
@@ -38,10 +48,34 @@ export async function main(argv) {
|
|
|
38
48
|
if (!verb || verb === '--help' || verb === '-h' || verb === 'help') { process.stdout.write(help); return 0; }
|
|
39
49
|
if (verb === 'cli') return (await import('./cli/command.mjs')).cliCommand(argv.slice(1));
|
|
40
50
|
if (verb === 'list') return (await import('./list.mjs')).listCommand(argv.slice(1));
|
|
51
|
+
if (verb === 'door') return (await import('./door/command.mjs')).doorCommand(argv.slice(1));
|
|
41
52
|
const target = locate(argv);
|
|
42
53
|
if (target.profile === 'unsupported') { process.stderr.write(unsupportedMessage(target.root, target.version) + '\n'); return 2; }
|
|
54
|
+
const first = target.profile === 'first-party', mode = first ? dispatcher(target.root).mode : undefined;
|
|
55
|
+
if (verb === 'queue') {
|
|
56
|
+
const [, name, separator, command] = argv;
|
|
57
|
+
if (!name || separator !== '--' || !command) { process.stderr.write('Usage: rer queue NAME -- COMMAND [ARGS]\n'); return 2; }
|
|
58
|
+
if (mode === 'package') return delegate(target, target.args);
|
|
59
|
+
return (await import('../onboarding/queued-entry.mjs')).queuedCommandLine(argv.slice(1));
|
|
60
|
+
}
|
|
43
61
|
// A checkout that pins its own rer package runs all of it, launching included.
|
|
44
|
-
|
|
62
|
+
// Until node_modules holds the pinned version, install, check and describe
|
|
63
|
+
// install it with the frozen lockfile (then run the pinned verb, so a fresh
|
|
64
|
+
// clone checks with the pinned bytes), the zero-setup launch installs it
|
|
65
|
+
// first, and every other verb says to install.
|
|
66
|
+
const pinned = first && mode !== 'package' ? pinnedPackage(target.root) : undefined;
|
|
67
|
+
if (pinned && !pinned.ready && !argv.includes('--help')) {
|
|
68
|
+
if (installsFirst.has(verb)) {
|
|
69
|
+
const { installPinned } = await import('./launch/install-pinned.mjs'), { LaunchFailure } = await import('./launch/failure.mjs');
|
|
70
|
+
try { await installPinned(target.root); }
|
|
71
|
+
catch (failure) { if (!(failure instanceof LaunchFailure)) throw failure; process.stderr.write(failure.message + '\n'); return failure.exitCode; }
|
|
72
|
+
return delegate(target, target.args);
|
|
73
|
+
}
|
|
74
|
+
if (verb !== 'dev') {
|
|
75
|
+
process.stderr.write(`${target.root} pins ${pinnedName} ${pinned.declared}, ${pinned.installed ? `but node_modules holds ${pinned.installed}` : 'which is not installed yet'}. Run rer install, then rer ${verb} again.\n`);
|
|
76
|
+
return 3;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
45
79
|
if (verb === 'dev' && mode && mode !== 'package' && !argv.includes('--help')) return (await import('./launch/dev.mjs')).launchDev(target);
|
|
46
80
|
// Every other server start or queued job on a vendored pin gets the person
|
|
47
81
|
// defaults too (a pinned package applies its own).
|
package/global/version.mjs
CHANGED
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
// checkout (or with --cwd DIR) a second line names the version that checkout
|
|
3
3
|
// pins: its package's, or the one its vendored copy recorded, which
|
|
4
4
|
// `rer describe` reports as cliVersion.
|
|
5
|
-
import {
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
7
|
-
import { dispatcher, locate } from './checkout.mjs';
|
|
5
|
+
import { dispatcher, locate, pinnedPackage } from './checkout.mjs';
|
|
8
6
|
import { describeCheckout } from './launch/facts.mjs';
|
|
9
7
|
import { ownManifest } from './package-info.mjs';
|
|
10
8
|
|
|
@@ -12,13 +10,15 @@ export async function versionCommand(argv) {
|
|
|
12
10
|
process.stdout.write(ownManifest().version + '\n');
|
|
13
11
|
const target = locate(argv);
|
|
14
12
|
if (target.profile !== 'first-party') return 0;
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
const pinned = pinnedPackage(target.root);
|
|
14
|
+
if (pinned) {
|
|
15
|
+
process.stdout.write(pinned.ready
|
|
16
|
+
? `This checkout (${target.root}) pins rer ${pinned.installed} (package).\n`
|
|
17
|
+
: `This checkout (${target.root}) pins rer ${pinned.declared} (package), ${pinned.installed ? `but node_modules holds ${pinned.installed}` : 'not installed yet'}: run rer install.\n`);
|
|
20
18
|
return 0;
|
|
21
19
|
}
|
|
20
|
+
const { mode, entry } = dispatcher(target.root);
|
|
21
|
+
if (mode === 'bundled') return 0;
|
|
22
22
|
let described;
|
|
23
23
|
try { described = await describeCheckout(target.root, entry, process.env); }
|
|
24
24
|
catch (failure) { process.stdout.write(`This checkout (${target.root}): its pinned rer version could not be read: ${failure.message.split('\n')[0]}\n`); return 0; }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readJson, discover } from '../operator-auth/identity/oidc/network.mjs';
|
|
2
2
|
import { PRESETS, MINIMUM_PROVIDER_VERSION } from '../operator-auth/identity/oidc/presets.mjs';
|
|
3
|
+
import { isLoopbackHost } from '../operator-auth/loopback.mjs';
|
|
3
4
|
export const INTERFACE = 'oidc-provider@1';
|
|
4
5
|
export const CALLBACK = '/.well-known/rer-link/callback';
|
|
5
6
|
export const PATHS = ['/auth/oidc/auth/callback','/auth/oidc/auth/logout'];
|
|
@@ -7,7 +8,7 @@ export const NETWORK = { timeoutMs:5000 };
|
|
|
7
8
|
export function insist(value, code = 'invalid_link', status = 400) { if (!value) throw Object.assign(new Error(code),{code,status}); }
|
|
8
9
|
export function originOf(value, local = false) {
|
|
9
10
|
let u;try { u=new URL(value); } catch { insist(false); }
|
|
10
|
-
insist(u.origin===value && !u.username && !u.password && (u.protocol==='https:' || local && u.protocol==='http:' &&
|
|
11
|
+
insist(u.origin===value && !u.username && !u.password && (u.protocol==='https:' || local && u.protocol==='http:' && isLoopbackHost(u.hostname)));
|
|
11
12
|
return u.origin;
|
|
12
13
|
}
|
|
13
14
|
export async function providerMetadata(issuer, fetcher, contract = INTERFACE) {
|
|
@@ -140,12 +140,45 @@ When a token lacks a permission, the CLI prints which one instead of only a stat
|
|
|
140
140
|
A seed carries a copy of `modules/deploy-kit/` from a Base pin. Refresh the copy, write a `deploy.json` from the plan the seed already builds, and point `deploy`, and the hosting step of `setup`, at `owner-cli.mjs`. Keep any throwaway controller only for proofs. In `deploy.json` declare:
|
|
141
141
|
|
|
142
142
|
- the bindings the app uses (`DB`, `FILES`, `JOBS` and so on), with each queue's consumer and dead-letter queue and each SQLite Durable Object class;
|
|
143
|
-
- the secrets the app reads, such as its session secret and the mail provider's key;
|
|
143
|
+
- the secrets the app reads, such as its session secret and the mail provider's key. A secret item may add `verifyStandIn`, the hosted-valid value `rer verify`'s hosted check binds in its place (`{"binding": "MAIL_PROVIDER", "environment": "MAIL_PROVIDER", "verifyStandIn": "resend"}`); the kit validates it (a non-empty string), leaves it out of the plan and its fingerprint, and never sends it or makes it a secret value;
|
|
144
144
|
- any Cron schedule and the public setup fields the app takes in `vars`;
|
|
145
145
|
- `originVar`, the variable that holds the app's public origin (for example `APP_ORIGIN`).
|
|
146
146
|
|
|
147
147
|
A seed whose setup provisions nothing declares the same bindings and deploys through its own `deploy` verb. Resource names change from `rer-seedproof-*` or a chosen prefix to `<project>-<environment>-*`. The kit refuses rather than adopts an earlier installation's resources.
|
|
148
148
|
|
|
149
|
+
### Run site (`rer deploy --site`)
|
|
150
|
+
|
|
151
|
+
A seed's run site (`run-<seed>.com`: its marketing homepage, `/run` and docs, written by `rer build --site`) deploys to the owner's account as a **Base-generated site Worker** (`site.mjs`). The seed declares only the apex domain, beside `deploy.json`, in the file its `commandAdapter` `site.declaration` names (Base's seeds: `deploy-site.json`):
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{ "version": 1, "domain": "run-cradle.com" }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The domain is an apex hostname: no scheme, path, port, upper case or `www.`. The plan (`sitePlan`) names:
|
|
158
|
+
|
|
159
|
+
- the Worker `<project>-<environment>--site`. Owner Worker names never contain `--`, so it can never be the installed app's Worker (`<project>-<environment>`), and a site deploy never reads or writes the app Worker or any of its resources;
|
|
160
|
+
- its one module, `site-worker.mjs` (`SITE_WORKER`): fixed content Base generates, with no seed code. Its only binding is the assets (`ASSETS`), it runs first on every request (`run_worker_first`), and it has no secrets, no schedules and workers.dev off. A request to `www.<domain>` gets a 301 to `https://<domain>` with path and query kept; every other request is answered from the assets exactly as an assets-only Worker would answer it: the `rer build --site` folder with `html_handling: auto-trailing-slash` (`/run` serves `run.html`) and `not_found_handling: 404-page`;
|
|
161
|
+
- the apex as a Workers Custom Domain, and `www.<domain>` as a second Custom Domain on the same Worker, so the www host has DNS and a certificate and its requests reach the Worker's redirect.
|
|
162
|
+
|
|
163
|
+
No zone ruleset is read or written. Every request now invokes the Worker: one page view costs one Worker request for the page and one per same-origin file it loads (the default seed's run site: at most 7 per uncached page view).
|
|
164
|
+
|
|
165
|
+
`rer deploy --site --dry-run` builds the site and prints that plan (`--json`: a `site` event and a result with `dryRun: true`, `deployed: false` and the same `site` object) with no provider request and nothing written, even with `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` set. Without `--dry-run` it needs both inputs. Ownership is read from the provider alone, so a deploy from a fresh checkout (a CI runner with no `.rer/`) recognises the site it deployed before:
|
|
166
|
+
|
|
167
|
+
- the Worker is the kit's when its latest deployment serves, at 100%, the Worker's newest version, and that version's message annotation is the kit's `rer-site:<fingerprint>` (every site upload sets it);
|
|
168
|
+
- a Custom Domain is the kit's when its service is the kit's Worker.
|
|
169
|
+
|
|
170
|
+
A read-only preflight reads the zone (it must be in this account, else `site-zone-missing`), the Worker and the zone's Custom Domains, and refuses before any write: `resource-name-collision` for a Worker of the name whose live version the kit did not upload, or for a hostname attached to another service (or to a Worker that is not the kit's); `owned-resource-replaced` for a kit Worker whose latest deployment is not the kit's upload (a dashboard rollback or a split deployment). It then uploads the assets and the site Worker, checks that the new version is live, turns workers.dev off, attaches each missing hostname, and reads the Worker and both domains back. A repeat attaches nothing new. `.rer/deploy/site-<environment>.json` receives a receipt for the person; it is never read. The real path is tested only against the fake Cloudflare API in `tests/site-provider.mjs`, where every zone ruleset path answers 403; it has not run against a live account.
|
|
171
|
+
|
|
172
|
+
The token needs, beyond the owner-account permissions above:
|
|
173
|
+
|
|
174
|
+
| Permission | Scope | Used for |
|
|
175
|
+
| --- | --- | --- |
|
|
176
|
+
| Workers Scripts: Edit | the account | the site Worker upload, its assets, versions and deployments, workers.dev, and Workers Custom Domains (`/workers/domains`) |
|
|
177
|
+
| Zone: Read | the run site zones | finding the zone by name |
|
|
178
|
+
| Workers Routes: Edit | the run site zones | Workers Custom Domains, which also create their DNS records and certificates |
|
|
179
|
+
|
|
180
|
+
No Single Redirect (dynamic redirect ruleset) permission is needed. Base's references declare reserved `.example` domains (`run-default-seed.example`, `run-<edition>.example` for a new foundation), so a real deploy of either stops at `site-zone-missing`.
|
|
181
|
+
|
|
149
182
|
## Migration safety
|
|
150
183
|
|
|
151
184
|
SQL files have ordered unique names and SHA-256 identities. `_rer_deploy_migrations` is permanent controller-owned history. Applied entries must be an unchanged prefix of the desired set. A new migration gets an `applying` claim before its SQL and completion marker are submitted together to the D1 batch query API. Any interrupted, failed or uncertain outcome remains blocked and must be inspected explicitly; the kit never automatically retries, rolls back user data, or marks an unknown outcome successful. Readback checks every final identity.
|
|
@@ -40,6 +40,16 @@ export async function collectFiles(root, directory = '', budget = {bytes:0,count
|
|
|
40
40
|
}
|
|
41
41
|
return files;
|
|
42
42
|
}
|
|
43
|
+
// Wrangler's .assetsignore in an assets folder: gitignore-style lines naming
|
|
44
|
+
// files that are never assets. The file itself is never one either.
|
|
45
|
+
export function assetsIgnore(text = '') {
|
|
46
|
+
const rules = String(text).split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#')).map(line => {
|
|
47
|
+
const negate = line.startsWith('!'), body = negate ? line.slice(1) : line, directory = body.endsWith('/'), pattern = body.replace(/\/+$/, '');
|
|
48
|
+
const glob = pattern.replace(/^\//, '').split('/').map(part => part === '**' ? '.*' : part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]*').replaceAll('?', '[^/]')).join('/');
|
|
49
|
+
return { negate, test: new RegExp(`^${pattern.includes('/') ? '' : '(?:.*/)?'}${glob}${directory ? '/.*' : '(?:/.*)?'}$`) };
|
|
50
|
+
});
|
|
51
|
+
return path => path === '.assetsignore' || rules.reduce((ignored, rule) => rule.test.test(path) ? !rule.negate : ignored, false);
|
|
52
|
+
}
|
|
43
53
|
/** Built files a deployed Worker must serve: everything for the browser except documents
|
|
44
54
|
* (the app answers those; asset html_handling redirects them) and dot paths (never public). */
|
|
45
55
|
export const publicAssetPaths = paths => paths.filter(path => !path.split('/').some(part => part.startsWith('.')) && extname(path) !== '.html');
|
|
@@ -18,7 +18,7 @@ const segment = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
|
18
18
|
const STATE_KIND = 'rer-deploy-owner-state';
|
|
19
19
|
const KINDS = ['d1','r2','kv','queues'];
|
|
20
20
|
const DECLARATION_KEYS = ['version','project','compatibilityDate','compatibilityFlags','d1','r2','kv','queues','durableObjects','secrets','crons','vars','originVar','services','workersDev','platforms','container'];
|
|
21
|
-
const ENTRY_KEYS = {d1:['binding'],r2:['binding','locationHint'],kv:['binding'],queues:['binding','consumer','deadLetterQueue'],durableObjects:['binding','className','storage'],secrets:['binding','environment'],services:['binding','project','entrypoint','allowedHosts','grant']};
|
|
21
|
+
const ENTRY_KEYS = {d1:['binding'],r2:['binding','locationHint'],kv:['binding'],queues:['binding','consumer','deadLetterQueue'],durableObjects:['binding','className','storage'],secrets:['binding','environment','verifyStandIn'],services:['binding','project','entrypoint','allowedHosts','grant']};
|
|
22
22
|
|
|
23
23
|
function refuse(code, kind, name) { const error = new Error(code); error.resource = `${kind}:${name}`; return error; }
|
|
24
24
|
|
|
@@ -110,7 +110,8 @@ export function ownerPlan(target, declaration, artifact, { localOnlyKeys = [] }
|
|
|
110
110
|
...(kv.length ? {kv} : {}),
|
|
111
111
|
queues:queues.map(item => ({...item,name:name(item.binding),...(item.deadLetterQueue === undefined ? {} : {deadLetterQueue:name(item.deadLetterQueue)})})),
|
|
112
112
|
durableObjects:entries(declaration,'durableObjects').map(item => ({...item})),
|
|
113
|
-
|
|
113
|
+
// verifyStandIn is only `rer verify`'s hosted-check value: never planned, sent or stored.
|
|
114
|
+
secrets:entries(declaration,'secrets').map(({verifyStandIn,...item}) => { assert(verifyStandIn === undefined || typeof verifyStandIn === 'string' && verifyStandIn.length > 0 && verifyStandIn.length <= 1024, 'invalid-verify-stand-in'); return item; }),
|
|
114
115
|
crons:[...(declaration.crons ?? [])],vars,
|
|
115
116
|
// Only declarations using these add the keys, so earlier plans and fingerprints are unchanged.
|
|
116
117
|
...(services.length ? {services} : {}),...(declaration.workersDev === false ? {workersDev:false} : {})});
|
|
@@ -182,7 +183,7 @@ async function checkSavedResourceIdentities(api, state) {
|
|
|
182
183
|
async function checkServiceWorkers(api, plan) {
|
|
183
184
|
for (const {service} of plan.services ?? []) if (await workerSettings(api, service) === null) throw refuse('service-worker-missing','service',service);
|
|
184
185
|
}
|
|
185
|
-
async function workerSettings(api, worker) {
|
|
186
|
+
export async function workerSettings(api, worker) {
|
|
186
187
|
try { return await api.request(`/workers/scripts/${worker}/settings`); }
|
|
187
188
|
catch (error) { if (error.status === 404) return null; throw error; }
|
|
188
189
|
}
|
|
@@ -220,7 +221,7 @@ async function checkOwnedWorker(api, plan, artifact, state) {
|
|
|
220
221
|
}
|
|
221
222
|
|
|
222
223
|
/** One deploy at a time per state file: an exclusive lock file for the whole run. */
|
|
223
|
-
async function withStateLock(path, run) {
|
|
224
|
+
export async function withStateLock(path, run) {
|
|
224
225
|
await mkdir(dirname(path),{recursive:true,mode:0o700});
|
|
225
226
|
const lock = `${path}.lock`;
|
|
226
227
|
let handle;
|
|
@@ -3,7 +3,7 @@ import { mkdir, writeFile, readdir, rm } from 'node:fs/promises';
|
|
|
3
3
|
import { resolve, join, relative } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { assert, hash, localPath } from './validation.mjs';
|
|
6
|
-
import { collectFiles, regularBytes, validateArtifact, MAX_ARTIFACT } from './artifact.mjs';
|
|
6
|
+
import { assetsIgnore, collectFiles, regularBytes, validateArtifact, MAX_ARTIFACT } from './artifact.mjs';
|
|
7
7
|
import { collectBuildModules } from './build-modules.mjs';
|
|
8
8
|
import { refuseLocalModels } from './local-only.mjs';
|
|
9
9
|
|
|
@@ -24,7 +24,8 @@ export async function packageBuild({root,source,runIdentity,productDirectory = '
|
|
|
24
24
|
const config = JSON.parse(await regularBytes(join(build,'wrangler.json'),1024*1024));
|
|
25
25
|
const main = localPath(relative(build,resolve(build,config.main)));
|
|
26
26
|
const modules = await collectBuildModules(build,config,main);
|
|
27
|
-
const
|
|
27
|
+
const ignored = assetsIgnore(await regularBytes(join(product,'dist/client/.assetsignore'),65536).then(String,error => { if (error.code === 'ENOENT') return ''; throw error; }));
|
|
28
|
+
const assets = (await collectFiles(join(product,'dist/client'))).filter(file => !ignored(file.path));
|
|
28
29
|
const migrations = [];
|
|
29
30
|
for (const name of (await readdir(join(product,'migrations/core'))).sort()) {
|
|
30
31
|
assert(/^[0-9]+_[a-zA-Z0-9_-]+\.sql$/.test(name),'unexpected-migration-file');
|
|
@@ -12,13 +12,15 @@ export function cloudflare({ accountId, token, fetch: request = globalThis.fetch
|
|
|
12
12
|
assert(hexId(accountId), 'invalid-cloudflare-account-id');
|
|
13
13
|
assert(typeof token === 'string' && token.trim() && !/[\r\n]/.test(token), 'invalid-cloudflare-api-token');
|
|
14
14
|
let requests = 0;
|
|
15
|
-
|
|
15
|
+
// `zone` scope: the zone-level API (/zones/...), for a run site's zone lookup and redirect rule.
|
|
16
|
+
async function envelope(path, { method = 'GET', body, assetToken, zone = false } = {}) {
|
|
16
17
|
assert(typeof path === 'string' && /^\/[a-z0-9/_?=&.,%-]+$/i.test(path) && !path.includes('..') && !path.split('?')[0].includes('%'), 'invalid-provider-path');
|
|
18
|
+
assert(!zone || /^\/zones(?:[/?]|$)/.test(path), 'invalid-provider-path');
|
|
17
19
|
assert(++requests <= maxRequests, 'provider-request-budget');
|
|
18
20
|
assert(!assetToken || path === '/workers/assets/upload?base64=true', 'invalid-asset-auth-target');
|
|
19
21
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
20
22
|
let response;
|
|
21
|
-
try { response = await request(`https://api.cloudflare.com/client/v4/accounts/${accountId}${path}`, {
|
|
23
|
+
try { response = await request(zone ? `https://api.cloudflare.com/client/v4${path}` : `https://api.cloudflare.com/client/v4/accounts/${accountId}${path}`, {
|
|
22
24
|
method, redirect: 'error', signal, headers: { Authorization: `Bearer ${assetToken ?? token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type':'application/json' } : {}) },
|
|
23
25
|
...(body === undefined ? {} : { body: body instanceof FormData ? body : JSON.stringify(body) }),
|
|
24
26
|
}); } catch { throw new Error('provider-transport-failed'); }
|
|
@@ -95,5 +97,6 @@ export function cloudflare({ accountId, token, fetch: request = globalThis.fetch
|
|
|
95
97
|
}
|
|
96
98
|
throw new Error('provider-pagination-limit');
|
|
97
99
|
}
|
|
98
|
-
return { accountId, envelope, list, async request(path, options) { return (await envelope(path, options)).result; }
|
|
100
|
+
return { accountId, envelope, list, async request(path, options) { return (await envelope(path, options)).result; },
|
|
101
|
+
async zoneRequest(path, options = {}) { return (await envelope(path, { ...options, zone: true })).result; } };
|
|
99
102
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { assert, hash, resourceName } from './validation.mjs';
|
|
4
|
+
import { ownerWorkerName, workerSettings } from './owner.mjs';
|
|
5
|
+
import { uploadAssets, workerOrigin } from './worker.mjs';
|
|
6
|
+
|
|
7
|
+
/* A seed's run site (run-<seed>.com): the folder `rer build --site` writes,
|
|
8
|
+
* served on the owner's own account by a Base-generated module Worker (fixed
|
|
9
|
+
* content, no seed code) whose only binding is its assets. The Worker runs
|
|
10
|
+
* first on every request: www.<domain> gets a 301 to the apex with path and
|
|
11
|
+
* query kept, and everything else is answered from the assets. It has no
|
|
12
|
+
* secrets and no schedules, workers.dev stays off, and its name can never be
|
|
13
|
+
* the installed app's Worker name, so a site deploy never touches the app or
|
|
14
|
+
* its resources. The apex and www.<domain> are Workers Custom Domains; no zone
|
|
15
|
+
* ruleset is read or written.
|
|
16
|
+
*
|
|
17
|
+
* Ownership is proven from the provider alone, so a deploy from a fresh
|
|
18
|
+
* checkout (a CI runner) recognises the site it deployed before: the Worker is
|
|
19
|
+
* the kit's when its latest deployment serves, at 100%, the Worker's newest
|
|
20
|
+
* version and that version carries the kit's `rer-site:<fingerprint>`
|
|
21
|
+
* annotation; a Custom Domain is the kit's when its service is the kit's
|
|
22
|
+
* Worker. Anything of the same name that is not the kit's is refused, never
|
|
23
|
+
* adopted. A local receipt is written for the person and never read. */
|
|
24
|
+
|
|
25
|
+
const RECEIPT_KIND = 'rer-deploy-site-receipt';
|
|
26
|
+
const APEX = /^(?!www\.)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62}$/;
|
|
27
|
+
const ANNOTATION = /^rer-site:[0-9a-f]{64}$/;
|
|
28
|
+
export const SITE_ASSETS_CONFIG = Object.freeze({ html_handling: 'auto-trailing-slash', not_found_handling: '404-page' });
|
|
29
|
+
export const SITE_MODULE = 'site-worker.mjs';
|
|
30
|
+
/** The site Worker. It names no domain: only the site's two Custom Domains route to it. */
|
|
31
|
+
export const SITE_WORKER = `// Run site Worker, generated by Base's deploy kit (rer deploy --site). Do not edit.
|
|
32
|
+
export default {
|
|
33
|
+
async fetch(request, env) {
|
|
34
|
+
const url = new URL(request.url);
|
|
35
|
+
if (url.hostname.startsWith('www.')) return new Response(null, { status: 301, headers: { location: \`https://\${url.hostname.slice(4)}\${url.pathname}\${url.search}\` } });
|
|
36
|
+
return env.ASSETS.fetch(request);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
function refuse(code, kind, name) { const error = new Error(code); error.resource = `${kind}:${name}`; return error; }
|
|
42
|
+
|
|
43
|
+
/** deploy-site.json: `{ "version": 1, "domain": "run-cradle.com" }`, the apex hostname only. */
|
|
44
|
+
export function siteDeclaration(value) {
|
|
45
|
+
assert(value && typeof value === 'object' && !Array.isArray(value) && value.version === 1 && Object.keys(value).sort().join() === 'domain,version', 'invalid-site-declaration');
|
|
46
|
+
assert(typeof value.domain === 'string' && value.domain.length <= 253 && APEX.test(value.domain), 'invalid-site-domain');
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The app is `<project>-<environment>`; owner names never hold `--`, so the site Worker cannot be it. */
|
|
51
|
+
export function siteWorkerName(project, environment) {
|
|
52
|
+
const worker = `${ownerWorkerName(project, environment)}--site`;
|
|
53
|
+
assert(resourceName(worker), 'owner-name-too-long');
|
|
54
|
+
return worker;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Everything a site deploy will do, with no provider request. */
|
|
58
|
+
export function sitePlan({ project, environment, compatibilityDate, declaration, assets }) {
|
|
59
|
+
const { domain } = siteDeclaration(declaration);
|
|
60
|
+
assert(/^\d{4}-\d{2}-\d{2}$/.test(compatibilityDate ?? ''), 'invalid-plan');
|
|
61
|
+
assert(typeof assets === 'string' && assets.startsWith('/'), 'invalid-site-assets');
|
|
62
|
+
return { version: 1, worker: siteWorkerName(project, environment), appWorker: ownerWorkerName(project, environment), module: SITE_MODULE, assets, compatibilityDate,
|
|
63
|
+
bindings: ['ASSETS'], secrets: [], crons: [], workersDev: false, runWorkerFirst: true, assetsConfig: { ...SITE_ASSETS_CONFIG },
|
|
64
|
+
domain, hostnames: [domain, `www.${domain}`],
|
|
65
|
+
redirects: [{ from: `https://www.${domain}/*`, to: `https://${domain}/`, status: 301, keeps: ['path', 'query'] }] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The version the Worker's latest deployment serves, as the kit reads ownership: null when there is no Worker. */
|
|
69
|
+
async function servedVersion(api, worker) {
|
|
70
|
+
if (!await workerSettings(api, worker)) return null;
|
|
71
|
+
const { deployments } = await api.request(`/workers/scripts/${worker}/deployments`);
|
|
72
|
+
assert(Array.isArray(deployments) && deployments.length > 0, 'invalid-deployments');
|
|
73
|
+
const [latest] = deployments, single = latest.versions?.length === 1 && latest.versions[0].percentage === 100 ? latest.versions[0].version_id : null;
|
|
74
|
+
const newest = (await api.request(`/workers/scripts/${worker}/versions`))?.items?.[0]?.id;
|
|
75
|
+
const version = single ? await api.request(`/workers/scripts/${worker}/versions/${single}`) : null;
|
|
76
|
+
const annotation = version?.annotations?.['workers/message'];
|
|
77
|
+
return { kit: typeof annotation === 'string' && ANNOTATION.test(annotation), annotation, current: Boolean(single) && single === newest };
|
|
78
|
+
}
|
|
79
|
+
/** The Worker is absent (false) or the kit's (true); anything else is refused. */
|
|
80
|
+
async function ownWorker(api, worker) {
|
|
81
|
+
const served = await servedVersion(api, worker);
|
|
82
|
+
if (!served) return false;
|
|
83
|
+
// Someone else's Worker of this name, or one whose live version the kit did not upload.
|
|
84
|
+
if (!served.kit) throw refuse('resource-name-collision', 'worker', worker);
|
|
85
|
+
// A kit version that is no longer the newest one served alone: a dashboard rollback or a split deployment.
|
|
86
|
+
if (!served.current) throw refuse('owned-resource-replaced', 'worker', worker);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function writeReceipt(path, receipt) {
|
|
91
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
92
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
93
|
+
await writeFile(temporary, JSON.stringify({ version: 1, kind: RECEIPT_KIND, ...receipt }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
94
|
+
await rename(temporary, path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Read-only preflight, then the site Worker upload and both Custom Domains, then readback. */
|
|
98
|
+
export async function siteDeploy({ api, plan, files, receiptPath }) {
|
|
99
|
+
assert(typeof receiptPath === 'string' && receiptPath.length, 'missing-receipt-path');
|
|
100
|
+
assert(plan?.module === SITE_MODULE && Array.isArray(files) && files.some(file => file.path === 'index.html'), 'invalid-site-assets');
|
|
101
|
+
const zones = await api.zoneRequest(`/zones?name=${plan.domain}&account.id=${api.accountId}`);
|
|
102
|
+
const zone = Array.isArray(zones) ? zones.find(row => row?.name === plan.domain && row.account?.id === api.accountId) : null;
|
|
103
|
+
if (!zone) throw refuse('site-zone-missing', 'zone', plan.domain);
|
|
104
|
+
// Preflight: the Worker and each hostname are the kit's own or absent before any write.
|
|
105
|
+
const existed = await ownWorker(api, plan.worker);
|
|
106
|
+
const attached = await api.request(`/workers/domains?zone_id=${zone.id}`);
|
|
107
|
+
assert(Array.isArray(attached), 'invalid-domain-list');
|
|
108
|
+
for (const hostname of plan.hostnames) {
|
|
109
|
+
const row = attached.find(item => item?.hostname === hostname);
|
|
110
|
+
if (row && (!existed || row.service !== plan.worker)) throw refuse('resource-name-collision', 'domain', hostname);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const fingerprint = hash({ files: files.map(({ path, sha256 }) => ({ path, sha256 })), config: plan.assetsConfig, module: hash(SITE_WORKER), compatibilityDate: plan.compatibilityDate });
|
|
114
|
+
const jwt = await uploadAssets(api, plan.worker, files);
|
|
115
|
+
const body = new FormData();
|
|
116
|
+
body.set('metadata', new Blob([JSON.stringify({ main_module: SITE_MODULE, compatibility_date: plan.compatibilityDate, bindings: [{ type: 'assets', name: 'ASSETS' }],
|
|
117
|
+
assets: { jwt, config: { ...plan.assetsConfig, run_worker_first: true } }, annotations: { 'workers/message': `rer-site:${fingerprint}` } })], { type: 'application/json' }));
|
|
118
|
+
body.set(SITE_MODULE, new Blob([SITE_WORKER], { type: 'application/javascript+module' }), SITE_MODULE);
|
|
119
|
+
await api.request(`/workers/scripts/${plan.worker}`, { method: 'PUT', body });
|
|
120
|
+
const uploaded = await servedVersion(api, plan.worker);
|
|
121
|
+
assert(uploaded?.current && uploaded.annotation === `rer-site:${fingerprint}`, 'site-upload-not-live');
|
|
122
|
+
await workerOrigin(api, plan.worker, { enable: true, workersDev: false });
|
|
123
|
+
for (const hostname of plan.hostnames) {
|
|
124
|
+
if (attached.some(row => row?.hostname === hostname)) continue;
|
|
125
|
+
const row = await api.request('/workers/domains', { method: 'PUT', body: { environment: 'production', hostname, service: plan.worker, zone_id: zone.id } });
|
|
126
|
+
assert(row?.hostname === hostname && row.service === plan.worker && typeof row.id === 'string', 'invalid-domain-attach');
|
|
127
|
+
}
|
|
128
|
+
// Readback: the kit's Worker live and both domains on it.
|
|
129
|
+
assert(await ownWorker(api, plan.worker), 'site-worker-missing');
|
|
130
|
+
const domains = await api.request(`/workers/domains?zone_id=${zone.id}`);
|
|
131
|
+
for (const hostname of plan.hostnames) assert(domains.some(row => row.hostname === hostname && row.service === plan.worker), 'domain-readback-mismatch');
|
|
132
|
+
const receipt = { worker: plan.worker, domain: plan.domain, zoneId: zone.id, fingerprint, files: files.length, hostnames: plan.hostnames, redirect: plan.redirects[0] };
|
|
133
|
+
await writeReceipt(receiptPath, receipt);
|
|
134
|
+
return { origin: `https://${plan.domain}`, receipt };
|
|
135
|
+
}
|
|
@@ -31,8 +31,11 @@ outside D1 and R2.
|
|
|
31
31
|
leaves-first, the subject row last) is deleted in a D1 batch that also holds
|
|
32
32
|
portability's `erasureStatements(table, key)` for that row and
|
|
33
33
|
`invalidateTransfersStatements()`. A batch holds at most `rows` dependents, so
|
|
34
|
-
deletion work is bounded.
|
|
35
|
-
|
|
34
|
+
deletion work is bounded. The first step reads and orders the declared closure
|
|
35
|
+
and queues what it leaves; every later step reads, verifies and deletes only its
|
|
36
|
+
own slice of that queue, and the last step reads the closure once more from the
|
|
37
|
+
subject and every remembered parent, so the work of an erasure is linear in its
|
|
38
|
+
rows. A guard on the operation's step
|
|
36
39
|
counter makes a concurrent runner fail with `erasure_busy` (409) instead of
|
|
37
40
|
committing twice.
|
|
38
41
|
- **ERASE-005 — Blobs after the commit.** For a declared `blob` edge, each row's
|
|
@@ -51,7 +54,7 @@ outside D1 and R2.
|
|
|
51
54
|
replaced with one label per erasure, `erased:<128 random bits>`. The label is
|
|
52
55
|
not derived from the subject, so nobody who holds the database or an older
|
|
53
56
|
archive can recompute it or test a guess against it. Declared `clear` columns
|
|
54
|
-
are overwritten. Audit rows for
|
|
57
|
+
are overwritten. Audit rows for a batch's dependents are relabelled in that batch, and every remembered dependent's in the final one. Temporary
|
|
55
58
|
scope preserves earlier keys until the final pass, catching audit rows written
|
|
56
59
|
after those dependents disappeared; that scope is then removed atomically.
|
|
57
60
|
- **ERASE-008 — Value-free receipt.** The receipt row holds the entity name,
|
|
@@ -62,7 +62,14 @@ if (receipt.phase !== 'complete') await env.JOBS.send({ erasure: opened.id }); /
|
|
|
62
62
|
`commit(id,{rows,until})` runs only the D1 phase: bounded atomic batches of at
|
|
63
63
|
most `rows` dependents (default 500), leaves first, with the subject row last.
|
|
64
64
|
Each deleted row's `erasureStatements` and `invalidateTransfersStatements()` go
|
|
65
|
-
in the same batch.
|
|
65
|
+
in the same batch.
|
|
66
|
+
The first step collects and orders the whole plan and queues what it does not delete
|
|
67
|
+
in the private scope table; each later step reads and verifies only its own slice, so the
|
|
68
|
+
work of an erasure is linear in its rows, however many steps it takes. The queue is
|
|
69
|
+
not trusted for completeness: once it is empty, one more step collects the graph again
|
|
70
|
+
from the subject and every remembered parent, and anything written meanwhile is
|
|
71
|
+
erased before the receipt says `complete`. Index the child columns an edge
|
|
72
|
+
matches on (`on`), as foreign keys usually are, or each step scans that table. `deleteBlobs(id,{until})` runs only the blob phase, after the
|
|
66
73
|
commit. `run` does both within the time budget. Every phase is safe to repeat,
|
|
67
74
|
and a concurrent runner gets `erasure_busy` (409). The receipt says `complete`
|
|
68
75
|
only after the completion transaction re-reads every declared dependent and audit path
|
|
@@ -4,7 +4,7 @@ import { statement, quote, chunks, randomHex } from './sql.mjs';
|
|
|
4
4
|
* nobody holding the database (or an old archive) can recompute or test it. */
|
|
5
5
|
export const newLabel = () => 'erased:' + randomHex(16);
|
|
6
6
|
|
|
7
|
-
export function auditStatements(db, d, plan, label, { only } = {}) {
|
|
7
|
+
export function auditStatements(db, d, plan, label, { only, relabel = true } = {}) {
|
|
8
8
|
const out = [];
|
|
9
9
|
for (const item of d.audit) {
|
|
10
10
|
if (only && item.of !== only) continue;
|
|
@@ -13,7 +13,7 @@ export function auditStatements(db, d, plan, label, { only } = {}) {
|
|
|
13
13
|
const set = [`${quote(item.column)}=?`, ...clear.map(([c]) => `${quote(c)}=?`)].join(',');
|
|
14
14
|
// A previously relabelled row is still this operation's audit: a writer
|
|
15
15
|
// may have changed its detail after the first cleanup.
|
|
16
|
-
out.push(statement(db, `UPDATE ${quote(item.table)} SET ${set} WHERE ${quote(item.column)}=?${item.where ? ` AND (${item.where})` : ''}`, label, ...clear.map(([, v]) => v), label));
|
|
16
|
+
if (relabel) out.push(statement(db, `UPDATE ${quote(item.table)} SET ${set} WHERE ${quote(item.column)}=?${item.where ? ` AND (${item.where})` : ''}`, label, ...clear.map(([, v]) => v), label));
|
|
17
17
|
for (const part of chunks(keys, 200)) {
|
|
18
18
|
out.push(statement(db, `UPDATE ${quote(item.table)} SET ${set} WHERE ${quote(item.column)} IN (SELECT value FROM json_each(?))${item.where ? ` AND (${item.where})` : ''}`,
|
|
19
19
|
label, ...clear.map(([, v]) => v), JSON.stringify(part)));
|
|
@@ -5,6 +5,7 @@ import { verificationStatements, childAbsenceStatements } from './verification.m
|
|
|
5
5
|
import { collect, matchSql, checkDeclared } from './plan.mjs';
|
|
6
6
|
import { auditStatements } from './audit.mjs';
|
|
7
7
|
import { progressStatement, committedStatement } from './receipt.mjs';
|
|
8
|
+
import { queueStatements, readQueue, consumeStatement, clearQueue, slicePlan, rootScopeExists } from './queue.mjs';
|
|
8
9
|
|
|
9
10
|
const where = (keys, table) => keys.get(table).pk.map(c => `${c === 'rowid' ? 'rowid' : quote(c)}=?`).join(' AND ');
|
|
10
11
|
const values = e => Object.values(e.key);
|
|
@@ -37,7 +38,8 @@ export async function deletionStatements(ctx, d, plan, entries, id, deleted) {
|
|
|
37
38
|
export async function firstStatements(ctx, d, plan, label) {
|
|
38
39
|
return d.beforeStatements ? await d.beforeStatements({ db: ctx.db, subject: plan.subject, label }) : [];
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
+
// `relabel: false` (a slice of the plan) leaves the table-wide relabel of earlier audit rows to the final batch.
|
|
42
|
+
export const cleanupStatements = (ctx, d, plan, label, options) => [...redactStatements(ctx.db, d, plan), ...auditStatements(ctx.db, d, plan, label, options)];
|
|
41
43
|
/** Commits with the subject row: a second redaction/audit pass catches rows written
|
|
42
44
|
* during a long erasure. Verify before removing the root (so triggers cannot hide
|
|
43
45
|
* new children through CASCADE), then after final policy effects. */
|
|
@@ -53,8 +55,43 @@ export async function finalStatements(ctx, d, plan, id, label, now, deleted) {
|
|
|
53
55
|
return out;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
const lost = error => {
|
|
59
|
+
if (/erasure_retained_missing/.test(String(error?.message ?? error))) throw new ErasureError('retained_row_missing', 409);
|
|
60
|
+
if (/CHECK constraint failed/.test(String(error?.message ?? error))) throw new ErasureError('erasure_busy', 409);
|
|
61
|
+
throw error;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A non-final step: deletes `entries` (a slice of the plan in deletion order)
|
|
65
|
+
* and leaves `rest` queued. Everything the batch verifies is bounded by the
|
|
66
|
+
* slice. Completeness is the final step's job, which collects the whole graph
|
|
67
|
+
* again from the subject and every remembered parent. */
|
|
68
|
+
async function sliceStep(ctx, d, op, entries, { first = null, rest = null } = {}) {
|
|
69
|
+
const { db, fence } = ctx, id = op.id, deleted = JSON.parse(op.deleted);
|
|
70
|
+
const plan = await slicePlan(db, d, JSON.parse(op.subject), entries);
|
|
71
|
+
const batch = [db.prepare('PRAGMA defer_foreign_keys=ON'),
|
|
72
|
+
guard(db, "EXISTS(SELECT 1 FROM rer_erasure_operations WHERE id=? AND step=? AND phase='deleting')", id, op.step),
|
|
73
|
+
...await fence.transfers()];
|
|
74
|
+
batch.push(...await scopeStatements(db, d, first ?? plan, id, first ? [...entries, first.root] : entries, { whole: Boolean(first) }));
|
|
75
|
+
if (first) batch.push(...await firstStatements(ctx, d, plan, op.label));
|
|
76
|
+
batch.push(...await verificationStatements(db, d, plan, id, op.label));
|
|
77
|
+
batch.push(...cleanupStatements(ctx, d, plan, op.label, { relabel: false }));
|
|
78
|
+
batch.push(...await deletionStatements(ctx, d, plan, entries, id, deleted));
|
|
79
|
+
batch.push(...await verificationStatements(db, d, plan, id, op.label, { intake: false }));
|
|
80
|
+
if (rest) batch.push(...queueStatements(db, id, rest));
|
|
81
|
+
else batch.push(consumeStatement(db, id, entries.at(-1).seq));
|
|
82
|
+
batch.push(progressStatement(db, id, op.step, deleted), db.prepare('DELETE FROM rer_erasure_guard'));
|
|
83
|
+
try { await db.batch(batch); }
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (/erasure_scope_changed/.test(String(error?.message ?? error))) { await clearQueue(db, id).run(); return 'changed'; }
|
|
86
|
+
return lost(error);
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
56
91
|
/** One bounded, atomic D1 commit of up to `rows` dependents, leaves first. The
|
|
57
|
-
* subject row commits with the last step. A concurrent runner loses the guard.
|
|
92
|
+
* subject row commits with the last step. A concurrent runner loses the guard.
|
|
93
|
+
* The first step collects and orders the whole plan and queues what it leaves;
|
|
94
|
+
* later steps take their slice from the queue until it is empty. */
|
|
58
95
|
export async function step(ctx, d, id, { rows = 500 } = {}) {
|
|
59
96
|
const { db, fence } = ctx;
|
|
60
97
|
const op = await statement(db, 'SELECT * FROM rer_erasure_operations WHERE id=?', id).first();
|
|
@@ -63,29 +100,33 @@ export async function step(ctx, d, id, { rows = 500 } = {}) {
|
|
|
63
100
|
const now = new Date().toISOString(), deleted = JSON.parse(op.deleted);
|
|
64
101
|
await checkDeclared(db, d);
|
|
65
102
|
insist(Number.isInteger(rows) && rows > 0, 'batch_rows');
|
|
103
|
+
if (op.step > 0) {
|
|
104
|
+
const slice = await readQueue(db, id, rows);
|
|
105
|
+
if (slice.length) {
|
|
106
|
+
insist(await rootScopeExists(db, id, d), 'erasure_scope_missing', 409);
|
|
107
|
+
return sliceStep(ctx, d, op, slice);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
66
110
|
const remembered = await readScope(db, id);
|
|
67
111
|
insist(op.step === 0 || remembered.some(e => e.table === d.root.table), 'erasure_scope_missing', 409);
|
|
68
112
|
const plan = await collect(db, d, JSON.parse(op.subject), remembered);
|
|
69
113
|
insist(plan && plan.root, 'subject_removed_outside_erasure', 409);
|
|
114
|
+
const take = plan.order.slice(0, rows), final = take.length === plan.order.length;
|
|
115
|
+
if (!final) return sliceStep(ctx, d, op, take, { first: op.step === 0 ? plan : null, rest: plan.order.slice(rows) });
|
|
70
116
|
const batch = [db.prepare('PRAGMA defer_foreign_keys=ON'),
|
|
71
117
|
guard(db, "EXISTS(SELECT 1 FROM rer_erasure_operations WHERE id=? AND step=? AND phase='deleting')", id, op.step),
|
|
72
118
|
...await fence.transfers()];
|
|
73
|
-
const take = plan.order.slice(0, rows), final = take.length === plan.order.length;
|
|
74
119
|
batch.push(...await scopeStatements(db, d, plan, id, [...take, plan.root]));
|
|
75
120
|
if (op.step === 0) batch.push(...await firstStatements(ctx, d, plan, op.label));
|
|
76
121
|
batch.push(...await verificationStatements(db, d, plan, id, op.label));
|
|
77
122
|
batch.push(...cleanupStatements(ctx, d, plan, op.label));
|
|
78
123
|
batch.push(...await deletionStatements(ctx, d, plan, take, id, deleted));
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
batch.push(progressStatement(db, id, op.step, deleted), db.prepare('DELETE FROM rer_erasure_guard'));
|
|
124
|
+
batch.push(...await finalStatements(ctx, d, plan, id, op.label, now, deleted));
|
|
125
|
+
batch.push(clearQueue(db, id), progressStatement(db, id, op.step, deleted), db.prepare('DELETE FROM rer_erasure_guard'));
|
|
83
126
|
try { await db.batch(batch); }
|
|
84
127
|
catch (error) {
|
|
85
128
|
if (/erasure_scope_changed/.test(String(error?.message ?? error))) return 'changed';
|
|
86
|
-
|
|
87
|
-
if (/CHECK constraint failed/.test(String(error?.message ?? error))) throw new ErasureError('erasure_busy', 409);
|
|
88
|
-
throw error;
|
|
129
|
+
return lost(error);
|
|
89
130
|
}
|
|
90
|
-
return
|
|
131
|
+
return false;
|
|
91
132
|
}
|