@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.
Files changed (109) hide show
  1. package/README.md +115 -2
  2. package/cli/README.md +28 -3
  3. package/cli/contract.mjs +4 -4
  4. package/cli/dev-identity.mjs +2 -1
  5. package/cli/door-mode.mjs +134 -0
  6. package/cli/door.mjs +263 -0
  7. package/cli/hosted-output.mjs +15 -5
  8. package/cli/hydration/browser.mjs +91 -0
  9. package/cli/hydration/cases.mjs +106 -0
  10. package/cli/hydration/check.mjs +105 -0
  11. package/cli/hydration/probe.mjs +164 -0
  12. package/cli/hydration/scripts.mjs +39 -0
  13. package/cli/hydration/verdict.mjs +85 -0
  14. package/cli/inspect.mjs +56 -9
  15. package/cli/lifecycle.mjs +36 -12
  16. package/cli/local.mjs +9 -3
  17. package/cli/main.mjs +2 -0
  18. package/cli/managed-server.mjs +52 -18
  19. package/cli/package-calls.mjs +81 -0
  20. package/cli/package-mode.mjs +46 -0
  21. package/cli/parse.mjs +14 -4
  22. package/cli/pnpm-runtime.mjs +96 -0
  23. package/cli/portability-worker.mjs +2 -2
  24. package/cli/runtime-front-door.mjs +4 -2
  25. package/cli/server-proxy.mjs +69 -5
  26. package/cli/server-registry.mjs +19 -11
  27. package/cli/settings.mjs +14 -0
  28. package/cli/site-check.mjs +96 -0
  29. package/cli/site-preview.mjs +53 -0
  30. package/cli/site-render.mjs +77 -0
  31. package/cli/site.mjs +109 -0
  32. package/global/CHANGELOG.md +63 -0
  33. package/global/README.md +115 -2
  34. package/global/checkout.mjs +24 -10
  35. package/global/cli/install.mjs +6 -2
  36. package/global/delegate.mjs +9 -7
  37. package/global/door/command.mjs +254 -0
  38. package/global/door/control.mjs +65 -0
  39. package/global/door/cookies.mjs +41 -0
  40. package/global/door/pages.mjs +49 -0
  41. package/global/door/policy.mjs +49 -0
  42. package/global/door/routes.mjs +45 -0
  43. package/global/door/server.mjs +255 -0
  44. package/global/door/sites.mjs +192 -0
  45. package/global/launch/dev.mjs +25 -23
  46. package/global/launch/door.mjs +36 -0
  47. package/global/launch/facts.mjs +3 -2
  48. package/global/launch/install-pinned.mjs +22 -0
  49. package/global/launch/prepare.mjs +41 -6
  50. package/global/launch/render.mjs +6 -1
  51. package/global/launch/state.mjs +10 -1
  52. package/global/launch/steps.mjs +24 -10
  53. package/global/launch/stop.mjs +89 -0
  54. package/global/launch/worker.mjs +30 -11
  55. package/global/list.mjs +6 -3
  56. package/global/main.mjs +37 -3
  57. package/global/version.mjs +8 -8
  58. package/modules/dependencies/protocol.mjs +2 -1
  59. package/modules/deploy-kit/README.md +34 -1
  60. package/modules/deploy-kit/artifact.mjs +10 -0
  61. package/modules/deploy-kit/owner.mjs +5 -4
  62. package/modules/deploy-kit/prepare.mjs +3 -2
  63. package/modules/deploy-kit/provider.mjs +6 -3
  64. package/modules/deploy-kit/site.mjs +135 -0
  65. package/modules/erasure/CONTRACT.md +6 -3
  66. package/modules/erasure/README.md +8 -1
  67. package/modules/erasure/audit.mjs +2 -2
  68. package/modules/erasure/cascade.mjs +52 -11
  69. package/modules/erasure/order.mjs +29 -22
  70. package/modules/erasure/plan.mjs +4 -2
  71. package/modules/erasure/queue.mjs +41 -0
  72. package/modules/erasure/scope.mjs +3 -3
  73. package/modules/erasure/verification.mjs +4 -2
  74. package/modules/mail/README.md +1 -1
  75. package/modules/mail/index.mjs +1 -1
  76. package/modules/mail/src/core/errors.mjs +9 -0
  77. package/modules/mail/src/development/index.d.mts +6 -0
  78. package/modules/mail/src/development/index.mjs +6 -3
  79. package/modules/mail/src/development/viewer-html.mjs +3 -3
  80. package/modules/mail/src/development/viewer.mjs +8 -1
  81. package/modules/operator-auth/dev/index.mjs +15 -5
  82. package/modules/operator-auth/http.mjs +2 -3
  83. package/modules/operator-auth/loopback.mjs +17 -0
  84. package/modules/owner-claim/verify.mjs +2 -1
  85. package/modules/portability/README.md +8 -0
  86. package/modules/portability/hash.mjs +6 -2
  87. package/modules/portability/import.mjs +1 -1
  88. package/modules/portability/legacy-files.mjs +4 -4
  89. package/modules/portability/legacy-safety.mjs +51 -10
  90. package/modules/portability/migration-history.mjs +15 -0
  91. package/modules/release/README.md +5 -3
  92. package/modules/release/produce.mjs +5 -2
  93. package/modules/release/source.mjs +15 -4
  94. package/onboarding/adapter-auth.mjs +4 -3
  95. package/onboarding/adapter-settings.mjs +21 -5
  96. package/onboarding/adapter.mjs +2 -1
  97. package/onboarding/app-fetch.mjs +79 -0
  98. package/onboarding/archive-output.d.mts +4 -2
  99. package/onboarding/archive-output.mjs +22 -10
  100. package/onboarding/dependency-approval.mjs +4 -3
  101. package/onboarding/local/environment.mjs +7 -2
  102. package/onboarding/operator-install.mjs +8 -2
  103. package/onboarding/operator-prerequisites.mjs +5 -2
  104. package/onboarding/queued-entry.mjs +9 -5
  105. package/operations/operations.mjs +11 -2
  106. package/package.json +75 -1
  107. package/scripts/publish-check/github_refs.py +28 -2
  108. package/scripts/publish-check/public_hygiene.py +35 -4
  109. 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, exactly as node scripts/rer.mjs VERB would.
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
- const mode = target.profile === 'first-party' ? dispatcher(target.root).mode : undefined;
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).
@@ -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 { readFileSync } from 'node:fs';
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 { mode, entry } = dispatcher(target.root);
16
- if (mode === 'bundled') return 0;
17
- if (mode === 'package') {
18
- const pinned = JSON.parse(readFileSync(join(dirname(dirname(entry)), 'package.json'), 'utf8')).version;
19
- process.stdout.write(`This checkout (${target.root}) pins rer ${pinned} (package).\n`);
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:' && ['localhost','127.0.0.1','[::1]'].includes(u.hostname)));
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
- secrets:entries(declaration,'secrets').map(item => ({...item})),
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 assets = await collectFiles(join(product,'dist/client'));
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
- async function envelope(path, { method = 'GET', body, assetToken } = {}) {
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. Scope/audit and completeness checks also read the
35
- declared closure; their cost grows with that closure. A guard on the operation's step
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 selected dependents are relabelled in every batch. Temporary
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. `deleteBlobs(id,{until})` runs only the blob phase, after the
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
- export const cleanupStatements = (ctx, d, plan, label) => [...redactStatements(ctx.db, d, plan), ...auditStatements(ctx.db, d, plan, label)];
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
- if (final) batch.push(...await finalStatements(ctx, d, plan, id, op.label, now, deleted));
80
- else batch.push(...await verificationStatements(db, d, plan, id, op.label, { intake: false }));
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
- if (/erasure_retained_missing/.test(String(error?.message ?? error))) throw new ErasureError('retained_row_missing', 409);
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 !final;
131
+ return false;
91
132
  }