@runeditrun/rer 0.1.1 → 0.2.1
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 +113 -2
- package/cli/README.md +32 -5
- package/cli/contract.mjs +5 -5
- package/cli/deploy.mjs +17 -3
- package/cli/detached-server.mjs +51 -0
- 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 +17 -7
- 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 +57 -10
- package/cli/lifecycle.mjs +36 -12
- package/cli/local.mjs +9 -3
- package/cli/main.mjs +3 -0
- package/cli/managed-server.mjs +62 -21
- package/cli/package-calls.mjs +81 -0
- package/cli/package-mode.mjs +46 -0
- package/cli/parse.mjs +15 -4
- package/cli/pnpm-runtime.mjs +96 -0
- package/cli/portability-worker.mjs +2 -2
- package/cli/release.mjs +1 -0
- package/cli/runtime-front-door.mjs +4 -2
- package/cli/server-proxy.mjs +69 -5
- package/cli/server-registry.mjs +10 -7
- package/cli/settings.mjs +14 -0
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +111 -0
- package/cli/site-client.mjs +50 -0
- package/cli/site-data.mjs +54 -0
- package/cli/site-entities.json +1 -0
- package/cli/site-input-assets.mjs +58 -0
- package/cli/site-inputs.mjs +79 -0
- package/cli/site-markup.mjs +102 -0
- package/cli/site-preview.mjs +53 -0
- package/cli/site-private.mjs +36 -0
- package/cli/site-references.mjs +93 -0
- package/cli/site-render.mjs +79 -0
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +130 -0
- package/global/CHANGELOG.md +88 -0
- package/global/README.md +113 -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 +21 -7
- package/global/launch/door.mjs +36 -0
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +38 -5
- package/global/launch/render.mjs +6 -1
- package/global/launch/steps.mjs +12 -7
- package/global/launch/worker.mjs +3 -3
- package/global/list.mjs +6 -3
- package/global/main.mjs +39 -3
- package/global/output.mjs +12 -0
- package/global/rer.mjs +3 -0
- 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/deploy.mjs +5 -2
- package/modules/deploy-kit/diagnostics.mjs +55 -0
- package/modules/deploy-kit/owner-cli.mjs +10 -4
- package/modules/deploy-kit/owner.mjs +8 -4
- package/modules/deploy-kit/prepare.mjs +3 -2
- package/modules/deploy-kit/provider.mjs +51 -9
- 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/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/dev/index.mjs +15 -5
- package/modules/operator-auth/http.mjs +2 -3
- package/modules/operator-auth/index.d.mts +4 -9
- package/modules/operator-auth/loopback.mjs +17 -0
- package/modules/operator-auth/page.mjs +1 -1
- package/modules/operator-auth/service.mjs +1 -0
- package/modules/operator-auth/signin/index.d.mts +11 -0
- package/modules/operator-auth/signin/index.mjs +10 -7
- 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 +68 -3
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +14 -2
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/source.mjs +15 -4
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- 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/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/onboarding/queued-entry.mjs +9 -5
- package/operations/operations.mjs +11 -2
- package/package.json +76 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/github_refs.py +28 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +63 -8
- package/scripts/publish-check/publication-config.json +16 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
|
@@ -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');
|
|
@@ -1,30 +1,71 @@
|
|
|
1
|
+
import { recordDeployFailure } from './diagnostics.mjs';
|
|
1
2
|
import { assert, hexId } from './validation.mjs';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
async function boundedText(response, limit = 4 * 1024 * 1024, onReadError) {
|
|
4
5
|
assert(response.body, 'empty-response');
|
|
5
6
|
const reader = response.body.getReader(); const chunks = []; let length = 0;
|
|
6
|
-
try { while (true) {
|
|
7
|
+
try { while (true) {
|
|
8
|
+
let chunk;
|
|
9
|
+
// Only a failed stream read is transport failure; retain size/shape codes.
|
|
10
|
+
try { chunk = await reader.read(); } catch (cause) { throw onReadError ? onReadError(cause) : cause; }
|
|
11
|
+
const { done, value } = chunk; if (done) break;
|
|
12
|
+
length += value.length; assert(length <= limit, 'response-too-large'); chunks.push(value);
|
|
13
|
+
} }
|
|
7
14
|
finally { await reader.cancel().catch(() => {}); }
|
|
8
|
-
|
|
15
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
16
|
+
}
|
|
17
|
+
export async function boundedJSON(response, limit) {
|
|
18
|
+
try { return JSON.parse(await boundedText(response,limit)); } catch (error) { if (error instanceof SyntaxError) throw new Error('malformed-response'); throw error; }
|
|
19
|
+
}
|
|
20
|
+
function transportOutput(error) {
|
|
21
|
+
const lines = []; const seen = new Set();
|
|
22
|
+
for (let cause = error; cause && !seen.has(cause) && seen.size < 4; cause = cause.cause) {
|
|
23
|
+
seen.add(cause);
|
|
24
|
+
lines.push([typeof cause.code === 'string' ? cause.code : '', typeof cause.message === 'string' ? cause.message : ''].filter(Boolean).join(': '));
|
|
25
|
+
}
|
|
26
|
+
return lines.filter(Boolean).join('\n');
|
|
9
27
|
}
|
|
10
28
|
/** Only this fixed origin and the explicitly selected account ever receive the token. */
|
|
11
29
|
export function cloudflare({ accountId, token, fetch: request = globalThis.fetch, timeoutMs = 30000, maxRequests = 500 }) {
|
|
12
30
|
assert(hexId(accountId), 'invalid-cloudflare-account-id');
|
|
13
31
|
assert(typeof token === 'string' && token.trim() && !/[\r\n]/.test(token), 'invalid-cloudflare-api-token');
|
|
14
32
|
let requests = 0;
|
|
15
|
-
|
|
33
|
+
// `zone` scope: the zone-level API (/zones/...), for a run site's zone lookup and redirect rule.
|
|
34
|
+
async function envelope(path, { method = 'GET', body, assetToken, zone = false } = {}) {
|
|
16
35
|
assert(typeof path === 'string' && /^\/[a-z0-9/_?=&.,%-]+$/i.test(path) && !path.includes('..') && !path.split('?')[0].includes('%'), 'invalid-provider-path');
|
|
36
|
+
assert(!zone || /^\/zones(?:[/?]|$)/.test(path), 'invalid-provider-path');
|
|
17
37
|
assert(++requests <= maxRequests, 'provider-request-budget');
|
|
18
38
|
assert(!assetToken || path === '/workers/assets/upload?base64=true', 'invalid-asset-auth-target');
|
|
39
|
+
// Asset-session credentials are discovered at runtime, outside the CLI env.
|
|
40
|
+
// Preserve complete text for the CLI's remaining redaction before truncation.
|
|
41
|
+
const redactAuth = text => [accountId,token,assetToken].filter(Boolean).flatMap(value=>[value,encodeURIComponent(value),JSON.stringify(value).slice(1,-1)]).sort((a,b)=>b.length-a.length).reduce((value,secret)=>value.split(secret).join('[REDACTED]'),text);
|
|
42
|
+
const transportFailure = cause => recordDeployFailure(new Error('provider-transport-failed'),{output:redactAuth(transportOutput(cause))});
|
|
19
43
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
20
44
|
let response;
|
|
21
|
-
try { response = await request(`https://api.cloudflare.com/client/v4/accounts/${accountId}${path}`, {
|
|
45
|
+
try { response = await request(zone ? `https://api.cloudflare.com/client/v4${path}` : `https://api.cloudflare.com/client/v4/accounts/${accountId}${path}`, {
|
|
22
46
|
method, redirect: 'error', signal, headers: { Authorization: `Bearer ${assetToken ?? token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type':'application/json' } : {}) },
|
|
23
47
|
...(body === undefined ? {} : { body: body instanceof FormData ? body : JSON.stringify(body) }),
|
|
24
|
-
}); } catch { throw
|
|
25
|
-
|
|
26
|
-
|
|
48
|
+
}); } catch (cause) { throw transportFailure(cause); }
|
|
49
|
+
const output = response.body ? await boundedText(response,undefined,transportFailure) : '';
|
|
50
|
+
let payload;
|
|
51
|
+
try { payload = JSON.parse(output); } catch { if (response.ok) throw new Error('malformed-response'); }
|
|
52
|
+
if (!response.ok || payload?.success === false) {
|
|
53
|
+
const error = new Error(response.ok ? 'provider-unsuccessful-or-malformed' : `provider-http-${response.status}`);
|
|
54
|
+
error.status = response.status;
|
|
55
|
+
throw recordDeployFailure(error,{providerCodes:(Array.isArray(payload?.errors) ? payload.errors : []).filter(x => Number.isSafeInteger(x?.code)).map(x=>x.code).slice(0,8),
|
|
56
|
+
output:redactAuth(Array.isArray(payload?.errors) ? payload.errors.filter(x=>typeof x?.message==='string').map(x=>x.message).join('\n') : output)});
|
|
57
|
+
}
|
|
27
58
|
assert(payload?.success === true && Object.hasOwn(payload,'result') && (!payload.errors || Array.isArray(payload.errors) && !payload.errors.length), 'provider-unsuccessful-or-malformed');
|
|
59
|
+
if (/^\/d1\/database\/[^/]+\/query$/.test(path) && Array.isArray(payload.result)) {
|
|
60
|
+
const failed = payload.result.filter(row => row?.success === false);
|
|
61
|
+
if (failed.length) {
|
|
62
|
+
// D1 can fail inside a successful HTTP/API envelope. Read only typed
|
|
63
|
+
// error fields; query results and metadata are never diagnostic output.
|
|
64
|
+
const errors = failed.flatMap(row => [typeof row.error === 'string' ? {message:row.error} : row.error, ...(Array.isArray(row.errors) ? row.errors : [])]);
|
|
65
|
+
throw recordDeployFailure(new Error('d1-query-failed'),{providerCodes:errors.filter(error=>Number.isSafeInteger(error?.code)).map(error=>error.code).slice(0,8),
|
|
66
|
+
output:redactAuth(errors.filter(error=>typeof error?.message==='string').map(error=>error.message).join('\n'))});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
28
69
|
return payload;
|
|
29
70
|
}
|
|
30
71
|
async function list(path, { field, pagination = 'page' } = {}) {
|
|
@@ -95,5 +136,6 @@ export function cloudflare({ accountId, token, fetch: request = globalThis.fetch
|
|
|
95
136
|
}
|
|
96
137
|
throw new Error('provider-pagination-limit');
|
|
97
138
|
}
|
|
98
|
-
return { accountId, envelope, list, async request(path, options) { return (await envelope(path, options)).result; }
|
|
139
|
+
return { accountId, envelope, list, async request(path, options) { return (await envelope(path, options)).result; },
|
|
140
|
+
async zoneRequest(path, options = {}) { return (await envelope(path, { ...options, zone: true })).result; } };
|
|
99
141
|
}
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { all, canonicalJson, insist, quote } from './sql.mjs';
|
|
2
2
|
import { matching } from './plan.mjs';
|
|
3
3
|
|
|
4
|
+
/** Every foreign key between two deleted tables, grouped per constraint. Schema
|
|
5
|
+
* only: it never reads a row, so a later step can use it without a plan. */
|
|
6
|
+
export async function foreignKeyLinks(db, d) {
|
|
7
|
+
const links = [];
|
|
8
|
+
for (const table of d.deleted) {
|
|
9
|
+
const groups = new Map();
|
|
10
|
+
for (const fk of await all(db, `PRAGMA foreign_key_list(${quote(table)})`)) {
|
|
11
|
+
if (!groups.has(fk.id)) groups.set(fk.id, []);
|
|
12
|
+
groups.get(fk.id).push(fk);
|
|
13
|
+
}
|
|
14
|
+
for (const group of groups.values()) {
|
|
15
|
+
const parentTable = d.deleted.find(name => name.toLowerCase() === group[0].table.toLowerCase());
|
|
16
|
+
if (parentTable) links.push({ table, parentTable, group });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return links;
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
/** Topological row order across every path, not the depth of the first visit.
|
|
5
23
|
* Actual FK links also constrain order, even if a row was reached by another edge. */
|
|
6
24
|
export async function deletionOrder(db, d, plan) {
|
|
@@ -18,28 +36,17 @@ export async function deletionOrder(db, d, plan) {
|
|
|
18
36
|
if (child) link(parent, child);
|
|
19
37
|
}
|
|
20
38
|
}
|
|
21
|
-
plan.foreignKeys =
|
|
22
|
-
for (const table of
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!parents) continue;
|
|
33
|
-
for (const parent of parents.values()) {
|
|
34
|
-
const parentKeys = plan.keys.get(parent.table).pk;
|
|
35
|
-
// Read the actual join: FK columns need not be part of a declared edge's projection.
|
|
36
|
-
const cols = plan.keys.get(table).pk;
|
|
37
|
-
const found = await all(db, `SELECT ${cols.map(c => c === 'rowid' ? 'rowid AS rowid' : quote(c)).join(',')} FROM ${quote(table)} WHERE ${group.map(f => `${quote(f.from)}=(SELECT ${quote(f.to || parentKeys[f.seq])} FROM ${quote(parent.table)} WHERE ${parentKeys.map(c => `${quote(c)}=?`).join(' AND ')})`).join(' AND ')}`, ...group.flatMap(() => parentKeys.map(c => parent.key[c])));
|
|
38
|
-
for (const row of found) {
|
|
39
|
-
const child = entries.get(table + '\0' + canonicalJson(Object.fromEntries(cols.map(c => [c, row[c]]))));
|
|
40
|
-
insist(child, `unreached_dependent:${table}:${parent.table}`, 409);
|
|
41
|
-
link(parent, child);
|
|
42
|
-
}
|
|
39
|
+
plan.foreignKeys = await foreignKeyLinks(db, d);
|
|
40
|
+
for (const { table, parentTable, group } of plan.foreignKeys) {
|
|
41
|
+
for (const parent of plan.rows.get(parentTable).values()) {
|
|
42
|
+
const parentKeys = plan.keys.get(parent.table).pk;
|
|
43
|
+
// Read the actual join: FK columns need not be part of a declared edge's projection.
|
|
44
|
+
const cols = plan.keys.get(table).pk;
|
|
45
|
+
const found = await all(db, `SELECT ${cols.map(c => c === 'rowid' ? 'rowid AS rowid' : quote(c)).join(',')} FROM ${quote(table)} WHERE ${group.map(f => `${quote(f.from)}=(SELECT ${quote(f.to || parentKeys[f.seq])} FROM ${quote(parent.table)} WHERE ${parentKeys.map(c => `${quote(c)}=?`).join(' AND ')})`).join(' AND ')}`, ...group.flatMap(() => parentKeys.map(c => parent.key[c])));
|
|
46
|
+
for (const row of found) {
|
|
47
|
+
const child = entries.get(table + '\0' + canonicalJson(Object.fromEntries(cols.map(c => [c, row[c]]))));
|
|
48
|
+
insist(child, `unreached_dependent:${table}:${parent.table}`, 409);
|
|
49
|
+
link(parent, child);
|
|
43
50
|
}
|
|
44
51
|
}
|
|
45
52
|
}
|
package/modules/erasure/plan.mjs
CHANGED
|
@@ -40,7 +40,9 @@ export function matchSql(item, parents) {
|
|
|
40
40
|
if (!item.on) return parents.map(p => ({ where: `(${item.where})`, values: (item.params ?? []).map(c => p[c]) }));
|
|
41
41
|
const pairs = Object.entries(item.on);
|
|
42
42
|
const tuples = parents.map(p => pairs.map(([, parent]) => p[parent])).filter(t => t.every(v => v !== null && v !== undefined));
|
|
43
|
-
|
|
43
|
+
// A set lookup, so an index on the child columns serves it; json_each's own
|
|
44
|
+
// columns (id, key, value...) are never in scope beside the child's.
|
|
45
|
+
const where = `(${pairs.map(([child]) => `${quote(item.table)}.${quote(child)}`).join(',')}) IN (SELECT ${pairs.map((_, i) => `json_extract(j.value,'$[${i}]')`).join(',')} FROM json_each(?) j)`;
|
|
44
46
|
return chunks(tuples, 200).map(part => ({ where, values: [JSON.stringify(part)] }));
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -71,7 +73,7 @@ export async function collect(db, d, subject, remembered = []) {
|
|
|
71
73
|
for (const [table, old] of known) {
|
|
72
74
|
const pk = keys.get(table).pk;
|
|
73
75
|
for (const part of chunks([...old.values()], 200)) {
|
|
74
|
-
const found = await all(db, `SELECT ${projection(columns.get(table))} FROM ${quote(table)} WHERE EXISTS(SELECT 1 FROM json_each(?) j WHERE ${pk.map((c, i) => `${quote(c)} IS json_extract(j.value,'$[${i}]')`).join(' AND ')})`, JSON.stringify(part.map(row => pk.map(c => row[c]))));
|
|
76
|
+
const found = await all(db, `SELECT ${projection(columns.get(table))} FROM ${quote(table)} WHERE EXISTS(SELECT 1 FROM json_each(?) j WHERE ${pk.map((c, i) => `${quote(table)}.${c === 'rowid' ? 'rowid' : quote(c)} IS json_extract(j.value,'$[${i}]')`).join(' AND ')})`, JSON.stringify(part.map(row => pk.map(c => row[c]))));
|
|
75
77
|
for (const row of found) { remember(table, row); const key = keyOf(table, row); rows.get(table).set(canonicalJson(key), { table, key, row, depth: 1 }); }
|
|
76
78
|
}
|
|
77
79
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { all, statement, canonicalJson } from './sql.mjs';
|
|
2
|
+
import { tableKeys } from './plan.mjs';
|
|
3
|
+
import { foreignKeyLinks } from './order.mjs';
|
|
4
|
+
|
|
5
|
+
/** What is left of a plan after its first step, in deletion order, held in the
|
|
6
|
+
* private scope table (no schema of its own). Each later step reads only its
|
|
7
|
+
* slice, so no step re-reads or re-checks the rows still waiting: the cost of
|
|
8
|
+
* the whole erasure stays linear in its rows. The slice is verified inside the
|
|
9
|
+
* step's batch; anything the queue does not know about (rows written after it
|
|
10
|
+
* was built) is found by the full collection that runs once the queue is empty. */
|
|
11
|
+
const seq = n => String(n).padStart(10, '0');
|
|
12
|
+
|
|
13
|
+
export const clearQueue = (db, id) => statement(db, "DELETE FROM rer_erasure_scope WHERE operation=? AND kind='queue'", id);
|
|
14
|
+
|
|
15
|
+
/** Replaces the operation's queue with `entries`; chunks stay small enough for one bound value. */
|
|
16
|
+
export function queueStatements(db, id, entries) {
|
|
17
|
+
const out = [clearQueue(db, id)], texts = entries.map((e, i) => canonicalJson({ i, t: e.table, k: e.key, r: e.row }));
|
|
18
|
+
for (let from = 0, to = 0; from < texts.length; from = to) {
|
|
19
|
+
for (let size = 0; to < texts.length && size + texts[to].length < 60000 && to - from < 200; to++) size += texts[to].length;
|
|
20
|
+
if (to === from) to++;
|
|
21
|
+
out.push(statement(db, "INSERT INTO rer_erasure_scope SELECT ?,'queue',printf('%010d',json_extract(j.value,'$.i')),'{}',j.value FROM json_each(?) j", id, `[${texts.slice(from, to).join(',')}]`));
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readQueue(db, id, limit) {
|
|
27
|
+
const rows = await all(db, "SELECT table_name AS seq,row_json FROM rer_erasure_scope WHERE operation=? AND kind='queue' ORDER BY table_name LIMIT ?", id, limit);
|
|
28
|
+
return rows.map(r => { const e = JSON.parse(r.row_json); return { seq: r.seq, table: e.t, key: e.k, row: e.r, depth: 1 }; });
|
|
29
|
+
}
|
|
30
|
+
export const consumeStatement = (db, id, last) => statement(db, "DELETE FROM rer_erasure_scope WHERE operation=? AND kind='queue' AND table_name<=?", id, last);
|
|
31
|
+
|
|
32
|
+
/** The part of a plan one step needs: only the rows it deletes are tracked, so
|
|
33
|
+
* every check in its batch is bounded by the slice, not by the rows waiting. */
|
|
34
|
+
export async function slicePlan(db, d, subject, entries) {
|
|
35
|
+
const keys = new Map();
|
|
36
|
+
for (const t of new Set([...d.tracked, ...d.redact.map(r => r.table)])) keys.set(t, await tableKeys(db, t));
|
|
37
|
+
const rows = new Map(d.tracked.map(t => [t, new Map()])), known = new Map(d.tracked.map(t => [t, new Map()]));
|
|
38
|
+
for (const e of entries) { rows.get(e.table).set(canonicalJson(e.key), e); known.get(e.table).set(canonicalJson(e.row), e.row); }
|
|
39
|
+
return { subject, root: null, keys, rows, entries, known, order: entries, foreignKeys: await foreignKeyLinks(db, d) };
|
|
40
|
+
}
|
|
41
|
+
export const rootScopeExists = async (db, id, d) => (await all(db, "SELECT 1 FROM rer_erasure_scope WHERE operation=? AND kind='graph' AND table_name=? LIMIT 1", id, d.root.table)).length > 0;
|
|
@@ -6,17 +6,17 @@ import { tableKeys } from './plan.mjs';
|
|
|
6
6
|
export async function readScope(db, id) {
|
|
7
7
|
return (await all(db, "SELECT table_name,key_json,row_json FROM rer_erasure_scope WHERE operation=? AND kind='graph'", id)).map(r => ({ table: r.table_name, key: JSON.parse(r.key_json), row: JSON.parse(r.row_json) }));
|
|
8
8
|
}
|
|
9
|
-
export async function scopeStatements(db, d, plan, id, entries = plan.entries) {
|
|
9
|
+
export async function scopeStatements(db, d, plan, id, entries = plan.entries, { whole = true } = {}) {
|
|
10
10
|
const out = [];
|
|
11
11
|
for (const { table, row, key } of entries) {
|
|
12
12
|
out.push(statement(db, "INSERT OR IGNORE INTO rer_erasure_scope VALUES(?,'graph',?,?,?)", id, table, canonicalJson(key), canonicalJson(row)));
|
|
13
13
|
}
|
|
14
|
-
for (const { table } of d.retain) {
|
|
14
|
+
for (const { table } of whole ? d.retain : []) {
|
|
15
15
|
const { pk } = await tableKeys(db, table);
|
|
16
16
|
const key = `json_object(${pk.flatMap(c => ["'" + c.replaceAll("'", "''") + "'", quote(c)]).join(',')})`;
|
|
17
17
|
out.push(statement(db, `INSERT OR IGNORE INTO rer_erasure_scope SELECT ?,'retain',?,${key},${key} FROM ${quote(table)}`, id, table));
|
|
18
18
|
}
|
|
19
|
-
if (d.root.redact && plan.root) {
|
|
19
|
+
if (whole && d.root.redact && plan.root) {
|
|
20
20
|
const identity = Object.fromEntries((d.root.redact.identityColumns ?? []).map(c => [c, plan.root.row[c]]));
|
|
21
21
|
out.push(statement(db, "INSERT INTO rer_erasure_scope SELECT ?,'identity',?,?,? WHERE NOT EXISTS(SELECT 1 FROM rer_erasure_scope WHERE operation=? AND kind='identity')", id, d.root.table, canonicalJson(plan.subject), canonicalJson(identity), id));
|
|
22
22
|
}
|
|
@@ -19,9 +19,11 @@ export async function verificationStatements(db, d, plan, id, label, { final = f
|
|
|
19
19
|
const pk = plan.keys.get(table).pk, parentPk = plan.keys.get(parentTable).pk;
|
|
20
20
|
const parents = [...plan.rows.get(parentTable).values()].map(e => parentPk.map(c => e.key[c]));
|
|
21
21
|
const children = [...plan.rows.get(table).values()].map(e => pk.map(c => e.key[c]));
|
|
22
|
-
|
|
22
|
+
if (!parents.length) continue;
|
|
23
|
+
const keyMatch = (alias, columns, json = 'j') => columns.map((c, i) => `${alias}.${quote(c)} IS json_extract(${json}.value,'$[${i}]')`).join(' AND ');
|
|
23
24
|
const joined = group.map(f => `c.${quote(f.from)}=p.${quote(f.to || parentPk[f.seq])}`).join(' AND ');
|
|
24
|
-
|
|
25
|
+
// Parents first, then their children through the key: cost follows the parents, not the child table.
|
|
26
|
+
out.push(verify(db, 'scope', `NOT EXISTS(SELECT 1 FROM json_each(?) j CROSS JOIN ${quote(parentTable)} p CROSS JOIN ${quote(table)} c WHERE ${keyMatch('p', parentPk)} AND ${joined} AND NOT EXISTS(SELECT 1 FROM json_each(?) k WHERE ${keyMatch('c', pk, 'k')}))`, JSON.stringify(parents), JSON.stringify(children)));
|
|
25
27
|
}
|
|
26
28
|
for (const edge of d.edges) for (const m of matchSql(edge, [...plan.known.get(edge.from).values()])) {
|
|
27
29
|
const pk = plan.keys.get(edge.table).pk, keys = [...plan.rows.get(edge.table).values()].map(e => pk.map(c => e.key[c]));
|
package/modules/mail/README.md
CHANGED
|
@@ -149,7 +149,7 @@ Network loss, redirects, HTTP 408 and 5xx are conservatively `uncertain`; 429 is
|
|
|
149
149
|
|
|
150
150
|
## Development and unsubscribe
|
|
151
151
|
|
|
152
|
-
Development requires all three: `provider:'development'`, `mode:'development'`, and explicit `dependencies.outbox.put`. It captures the prepared message with its raw attachment bytes and returns `development`, never remote acceptance. Configure storage only in your development host; no route is mounted automatically and no network replay is provided. Treat captured messages and sign-in links as sensitive local data.
|
|
152
|
+
Development requires all three: `provider:'development'`, `mode:'development'`, and explicit `dependencies.outbox.put`. It captures the prepared message with its raw attachment bytes and returns `development`, never remote acceptance. A storage failure throws `DevelopmentOutboxError` (`code: 'development_outbox_failed'`, `retryable: true`): the message is valid, so a queue consumer retries it. Invalid input throws `MailValidationError`, which is permanent and is dropped. Configure storage only in your development host; no route is mounted automatically and no network replay is provided. Treat captured messages and sign-in links as sensitive local data.
|
|
153
153
|
|
|
154
154
|
### Adopting the local outbox
|
|
155
155
|
|
package/modules/mail/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createMailSender } from './src/sender.mjs';
|
|
2
2
|
|
|
3
|
-
export { MailValidationError } from './src/core/errors.mjs';
|
|
3
|
+
export { MailValidationError, DevelopmentOutboxError } from './src/core/errors.mjs';
|
|
4
4
|
export { serializeSendGridMessage } from './src/adapters/sendgrid/index.mjs';
|
|
5
5
|
export { verifyWebhook, MAX_WEBHOOK_BYTES, createDeliveryEventIntake, createD1EventStore } from './src/events/index.mjs';
|
|
6
6
|
export { normalizeInbound, createResendAttachmentFetcher } from './src/inbound/index.mjs';
|
|
@@ -7,3 +7,12 @@ export class MailValidationError extends Error {
|
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
export function fail(code) { throw new MailValidationError(code); }
|
|
10
|
+
/** The development outbox could not store the message. The message is fine: send it again. */
|
|
11
|
+
export class DevelopmentOutboxError extends Error {
|
|
12
|
+
constructor() {
|
|
13
|
+
super('Development outbox storage failed; the same message may succeed when sent again.');
|
|
14
|
+
this.name = 'DevelopmentOutboxError';
|
|
15
|
+
this.code = 'development_outbox_failed';
|
|
16
|
+
this.retryable = true;
|
|
17
|
+
}
|
|
18
|
+
}
|