${title}
${copy}
diff --git a/docs/EXTERNAL_QUALIFICATION.md b/docs/EXTERNAL_QUALIFICATION.md index 274395b9..1e821de3 100644 --- a/docs/EXTERNAL_QUALIFICATION.md +++ b/docs/EXTERNAL_QUALIFICATION.md @@ -40,3 +40,5 @@ For account-quota facts, consult the [official GitHub Actions billing documentat ## Qualification-soak duration is not optional The named workloads are immutable inputs: CI = 100K rows / 30 seconds of CDC, SMALL = 1M / 1 hour, MEDIUM = 10M / 24 hours, LARGE = 100M / 72 hours, INDUSTRIAL = 1B / 7 days. The separate production qualification class also requires the complete cumulative identity, authority, crash, content-verification and HA gates in the readiness plan. Overrides receive a CUSTOM label and **never** inherit an immutable workload claim. The direct `external-ci.sh` command only executes the CI workload by default; its success cannot certify any higher class. Long-duration workloads require real isolated infrastructure and independent attestation; if no environment can execute them, the claim remains NOT_QUALIFIED. + +A test-process exit status alone is insufficient: standalone PostgreSQL conformance and red-team runners emit Node TAP and must have nonzero executed passing tests with exactly zero skips, TODO, failed or cancelled cases. The fail-closed `assert-test-execution.js` rejects empty, truncated or contradictory output. Both the transcripts and verification logs are checksummed in the external evidence bundle. This rule is mandatory even when PostgreSQL tests contain environment-based `skip` branches. diff --git a/docs/strict-local-authorization.md b/docs/strict-local-authorization.md new file mode 100644 index 00000000..0ce2d766 --- /dev/null +++ b/docs/strict-local-authorization.md @@ -0,0 +1,58 @@ +# Strict local migration scopes + +SPOOL's loopback daemon supports `requireMigrationScopes: true` for a +fail-closed *local* operator deployment. It does not turn the daemon into a +public network service or provide enterprise identity federation. + +```js +import { createSpoolDaemon } from '../src/daemon/spoold.js'; + +const daemon = createSpoolDaemon({ + service, // your initialized SpoolCommandService + host: '127.0.0.1', + requireMigrationScopes: true, + localIdentities: [ + { + token: process.env.SPOOL_OPERATOR_A_TOKEN, + principal: 'operator:alice', + commands: ['inspect', 'plan', 'status', 'approve', 'run', 'receipt'], + migrationIds: ['preprovisioned-migration-A'] + }, + { + token: process.env.SPOOL_OPERATOR_B_TOKEN, + principal: 'operator:bob', + commands: ['inspect', 'plan', 'status', 'receipt'], + migrationIds: ['preprovisioned-migration-A'] + } + ] +}); +``` + +Each token must be a separate high-entropy secret at least 32 UTF-8 bytes long; +do not embed it in a repository, URL, browser, or client-side bundle. Never +configure the legacy shared `token` simultaneously with `localIdentities`. +Startup fails if any strict identity has no explicit, nonempty migration +allowlist. A matching bearer cannot execute ungranted commands, impersonate +another `request.principal`, inspect another migration ID, or invoke a +top-level cutover/status endpoint with a different migration ID. Mismatched +`request.migrationId` and `migrationId` fail before dispatch. The API +responds 403, with no corresponding command-service invocation. + +**Security boundary:** This is exact migration-ID authorization for one +loopback process, *not* tenant data-source isolation. SPOOL's migration IDs and +plan connection references may be supplied by callers; protecting customer +data requires a separately trusted binding from each identity to immutable +source and target resource identities. This setting does not implement +OIDC/JWT verification, mTLS, global session revocation, approval separation +between distinct humans, audit log export, or distributed role administration. +The signed plan/repair approval record still binds to one request principal. +For now a working local approve-and-run path needs that same bound principal, +so the example grants Alice both commands. Bob's read-only identity illustrates +migration-scoped visibility; it is **not** a second approving authority. +Do **not** interpret separate `approve` and `run` grants as a completed +two-person authorization workflow. + +Legacy single-token mode is retained for backwards compatibility and remains +**unsuitable as proof of enterprise authorization**. Enable strict mode for +scoped local operation only; production enterprise release remains gated on +independent identity, policy, and separation-of-duties validation. diff --git a/product.css b/product.css index 3dbca342..3f7d4f0d 100644 --- a/product.css +++ b/product.css @@ -1 +1,3 @@ -.product-main{max-width:1240px;margin:0 auto;padding-bottom:72px}.product-hero{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(320px,.85fr);gap:40px;align-items:center;padding:76px 32px 34px}.compact-product-hero{grid-template-columns:minmax(0,1fr);max-width:980px}.product-hero h1{font-size:clamp(2.5rem,6vw,5.6rem);line-height:.96;letter-spacing:-.055em;margin:18px 0 20px}.product-hero h1 em{font-style:normal;color:#99f7c8}.product-hero .lede{max-width:800px;color:rgba(255,255,255,.72);font-size:1.08rem;line-height:1.72}.product-topbar .primary-nav{gap:18px}.runtime-diagram{display:flex;flex-direction:column;gap:9px;padding:24px;border:1px solid rgba(153,247,200,.16);background:linear-gradient(155deg,rgba(17,21,19,.98),rgba(7,9,8,.98));border-radius:22px;box-shadow:0 28px 90px rgba(0,0,0,.32)}.runtime-diagram span,.runtime-diagram b,.runtime-diagram strong{display:block;padding:11px 13px;border:1px solid rgba(255,255,255,.075);border-radius:10px;background:rgba(255,255,255,.025);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.82rem}.runtime-diagram span{color:rgba(255,255,255,.62)}.runtime-diagram b{font-weight:600;color:rgba(255,255,255,.86)}.runtime-diagram strong{border-color:rgba(153,247,200,.28);color:#99f7c8;background:rgba(153,247,200,.06)}.surface-grid,.proof-grid,.service-grid,.failure-grid,.example-score,.security-matrix{display:grid;gap:14px}.surface-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.proof-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.service-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.failure-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.example-score{grid-template-columns:repeat(3,minmax(0,1fr))}.security-matrix{grid-template-columns:repeat(2,minmax(0,1fr))}.surface-grid article,.proof-grid article,.service-grid article,.failure-grid article,.example-score article,.security-matrix article{border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.025);border-radius:18px;padding:22px;min-width:0}.surface-grid article.accent,.service-grid article.accent{border-color:rgba(153,247,200,.26);background:linear-gradient(145deg,rgba(153,247,200,.07),rgba(255,255,255,.025))}.surface-grid article>span,.service-grid article>span,.example-score article>span,.security-matrix article>span{display:inline-block;color:#99f7c8;font-size:.7rem;letter-spacing:.13em;text-transform:uppercase}.surface-grid h3,.proof-grid h3,.service-grid h2,.failure-grid h3,.security-matrix h2{margin:10px 0 8px}.surface-grid p,.proof-grid p,.service-grid p,.failure-grid p,.example-score p,.security-matrix p{color:rgba(255,255,255,.66);line-height:1.6}.surface-grid a,.text-link{color:#99f7c8;text-decoration:none}.proof-grid b{display:inline-grid;place-items:center;width:32px;height:32px;border-radius:50%;border:1px solid rgba(153,247,200,.25);color:#99f7c8;font-size:.72rem}.proof-grid code,.failure-grid code{display:inline-block;margin:4px 0;color:#c7ffe2}.deny-list,.number-list{margin:18px 0 0;padding:0;list-style:none}.deny-list li,.number-list li{position:relative;padding:9px 0 9px 28px;border-bottom:1px solid rgba(255,255,255,.06);color:rgba(255,255,255,.72)}.deny-list li:before{content:'×';position:absolute;left:2px;color:#ff9b9b}.number-list{counter-reset:step}.number-list li{counter-increment:step;padding-left:38px}.number-list li:before{content:counter(step,decimal-leading-zero);position:absolute;left:0;color:#99f7c8;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.75rem}.code-workflow{display:grid;grid-template-columns:.8fr 1.2fr;gap:30px;align-items:start;border:1px solid rgba(153,247,200,.16);border-radius:22px;padding:28px;background:rgba(255,255,255,.02)}.code-workflow pre{margin:0;padding:22px;border-radius:14px;background:#050606;border:1px solid rgba(255,255,255,.08);overflow:auto;color:#c7ffe2;line-height:1.7}.example-score article strong{display:block;font-size:1.8rem;margin:8px 0}.evidence-stack{display:flex;flex-direction:column;gap:8px;min-width:260px}.evidence-stack span,.evidence-stack strong{padding:10px 12px;border:1px solid rgba(255,255,255,.08);border-radius:9px;background:rgba(255,255,255,.02);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem}.evidence-stack strong{border-color:rgba(153,247,200,.28);color:#99f7c8}.privacy-note{font-size:.85rem;color:rgba(255,255,255,.55)!important;margin-top:14px}.complete-product-band{border-top:1px solid rgba(153,247,200,.1);border-bottom:1px solid rgba(153,247,200,.1);background:linear-gradient(180deg,rgba(153,247,200,.025),transparent)}.product-footer{max-width:1240px;margin:0 auto}.product-main .section{padding-left:32px;padding-right:32px}.product-main .callout{align-items:center}.product-main .check-list li code{font-size:.82rem}@media(max-width:1100px){.proof-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.surface-grid{grid-template-columns:1fr}.product-topbar .primary-nav{gap:11px;font-size:.83rem}}@media(max-width:900px){.product-hero{grid-template-columns:1fr;padding:54px 20px 24px}.product-main .section{padding-left:20px;padding-right:20px}.service-grid,.security-matrix,.example-score,.failure-grid,.proof-grid{grid-template-columns:1fr}.code-workflow{grid-template-columns:1fr;padding:20px}.product-topbar .primary-nav{overflow-x:auto;max-width:55vw;white-space:nowrap}.product-topbar .status-pill{display:none}.evidence-stack{min-width:0;width:100%}}@media(max-width:620px){.product-hero h1{font-size:clamp(2.25rem,12vw,3.5rem)}.product-topbar .primary-nav{display:none}.product-main .section{padding-left:16px;padding-right:16px}.product-hero{padding-left:16px;padding-right:16px}.runtime-diagram,.surface-grid article,.proof-grid article,.service-grid article,.failure-grid article,.example-score article,.security-matrix article{border-radius:14px;padding:18px}} \ No newline at end of file +.product-main{max-width:1240px;margin:0 auto;padding-bottom:72px}.product-hero{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(320px,.85fr);gap:40px;align-items:center;padding:76px 32px 34px}.compact-product-hero{grid-template-columns:minmax(0,1fr);max-width:980px}.product-hero h1{font-size:clamp(2.5rem,6vw,5.6rem);line-height:.96;letter-spacing:-.055em;margin:18px 0 20px}.product-hero h1 em{font-style:normal;color:#99f7c8}.product-hero .lede{max-width:800px;color:rgba(255,255,255,.72);font-size:1.08rem;line-height:1.72}.product-topbar .primary-nav{gap:18px}.runtime-diagram{display:flex;flex-direction:column;gap:9px;padding:24px;border:1px solid rgba(153,247,200,.16);background:linear-gradient(155deg,rgba(17,21,19,.98),rgba(7,9,8,.98));border-radius:22px;box-shadow:0 28px 90px rgba(0,0,0,.32)}.runtime-diagram span,.runtime-diagram b,.runtime-diagram strong{display:block;padding:11px 13px;border:1px solid rgba(255,255,255,.075);border-radius:10px;background:rgba(255,255,255,.025);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.82rem}.runtime-diagram span{color:rgba(255,255,255,.62)}.runtime-diagram b{font-weight:600;color:rgba(255,255,255,.86)}.runtime-diagram strong{border-color:rgba(153,247,200,.28);color:#99f7c8;background:rgba(153,247,200,.06)}.surface-grid,.proof-grid,.service-grid,.failure-grid,.example-score,.security-matrix{display:grid;gap:14px}.surface-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.proof-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.service-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.failure-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.example-score{grid-template-columns:repeat(3,minmax(0,1fr))}.security-matrix{grid-template-columns:repeat(2,minmax(0,1fr))}.surface-grid article,.proof-grid article,.service-grid article,.failure-grid article,.example-score article,.security-matrix article{border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.025);border-radius:18px;padding:22px;min-width:0}.surface-grid article.accent,.service-grid article.accent{border-color:rgba(153,247,200,.26);background:linear-gradient(145deg,rgba(153,247,200,.07),rgba(255,255,255,.025))}.surface-grid article>span,.service-grid article>span,.example-score article>span,.security-matrix article>span{display:inline-block;color:#99f7c8;font-size:.7rem;letter-spacing:.13em;text-transform:uppercase}.surface-grid h3,.proof-grid h3,.service-grid h2,.failure-grid h3,.security-matrix h2{margin:10px 0 8px}.surface-grid p,.proof-grid p,.service-grid p,.failure-grid p,.example-score p,.security-matrix p{color:rgba(255,255,255,.66);line-height:1.6}.surface-grid a,.text-link{color:#99f7c8;text-decoration:none}.proof-grid b{display:inline-grid;place-items:center;width:32px;height:32px;border-radius:50%;border:1px solid rgba(153,247,200,.25);color:#99f7c8;font-size:.72rem}.proof-grid code,.failure-grid code{display:inline-block;margin:4px 0;color:#c7ffe2}.deny-list,.number-list{margin:18px 0 0;padding:0;list-style:none}.deny-list li,.number-list li{position:relative;padding:9px 0 9px 28px;border-bottom:1px solid rgba(255,255,255,.06);color:rgba(255,255,255,.72)}.deny-list li:before{content:'×';position:absolute;left:2px;color:#ff9b9b}.number-list{counter-reset:step}.number-list li{counter-increment:step;padding-left:38px}.number-list li:before{content:counter(step,decimal-leading-zero);position:absolute;left:0;color:#99f7c8;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.75rem}.code-workflow{display:grid;grid-template-columns:.8fr 1.2fr;gap:30px;align-items:start;border:1px solid rgba(153,247,200,.16);border-radius:22px;padding:28px;background:rgba(255,255,255,.02)}.code-workflow pre{margin:0;padding:22px;border-radius:14px;background:#050606;border:1px solid rgba(255,255,255,.08);overflow:auto;color:#c7ffe2;line-height:1.7}.example-score article strong{display:block;font-size:1.8rem;margin:8px 0}.evidence-stack{display:flex;flex-direction:column;gap:8px;min-width:260px}.evidence-stack span,.evidence-stack strong{padding:10px 12px;border:1px solid rgba(255,255,255,.08);border-radius:9px;background:rgba(255,255,255,.02);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem}.evidence-stack strong{border-color:rgba(153,247,200,.28);color:#99f7c8}.privacy-note{font-size:.85rem;color:rgba(255,255,255,.55)!important;margin-top:14px}.complete-product-band{border-top:1px solid rgba(153,247,200,.1);border-bottom:1px solid rgba(153,247,200,.1);background:linear-gradient(180deg,rgba(153,247,200,.025),transparent)}.product-footer{max-width:1240px;margin:0 auto}.product-main .section{padding-left:32px;padding-right:32px}.product-main .callout{align-items:center}.product-main .check-list li code{font-size:.82rem}@media(max-width:1100px){.proof-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.surface-grid{grid-template-columns:1fr}.product-topbar .primary-nav{gap:11px;font-size:.83rem}}@media(max-width:900px){.product-hero{grid-template-columns:1fr;padding:54px 20px 24px}.product-main .section{padding-left:20px;padding-right:20px}.service-grid,.security-matrix,.example-score,.failure-grid,.proof-grid{grid-template-columns:1fr}.code-workflow{grid-template-columns:1fr;padding:20px}.product-topbar .primary-nav{overflow-x:auto;max-width:55vw;white-space:nowrap}.product-topbar .status-pill{display:none}.evidence-stack{min-width:0;width:100%}}@media(max-width:620px){.product-hero h1{font-size:clamp(2.25rem,12vw,3.5rem)}.product-topbar .primary-nav{display:none}.product-main .section{padding-left:16px;padding-right:16px}.product-hero{padding-left:16px;padding-right:16px}.runtime-diagram,.surface-grid article,.proof-grid article,.service-grid article,.failure-grid article,.example-score article,.security-matrix article{border-radius:14px;padding:18px}} +/* User-first onboarding and opt-in developer diagnostics. */ +.start-paths{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.start-paths article{display:flex;flex-direction:column;align-items:flex-start;gap:14px;min-width:0;padding:27px;border:1px solid rgba(255,255,255,.12);border-radius:20px;background:linear-gradient(155deg,rgba(153,247,200,.045),rgba(255,255,255,.015))}.start-paths article>span{color:#99f7c8;font-size:.72rem;letter-spacing:.1em;font-weight:750}.start-paths h2{margin:0;font-size:clamp(1.2rem,2vw,1.65rem);line-height:1.2}.start-paths p{margin:0;color:rgba(255,255,255,.72);line-height:1.7}.start-paths .path-limit{font-size:.84rem;color:rgba(255,255,255,.53);margin-top:auto}.start-paths article>a{margin-top:auto}.guided-panel{padding:28px;border:1px solid rgba(153,247,200,.19);border-radius:19px;background:rgba(153,247,200,.04)}.guided-panel p{max-width:840px;color:rgba(255,255,255,.72);line-height:1.75}.guided-panel h2{font-size:clamp(1.35rem,3vw,2rem)}.guided-home-band{display:flex;justify-content:space-between;align-items:center;gap:25px;border:1px solid rgba(153,247,200,.18);border-radius:18px;padding:26px!important;margin-top:8px;margin-bottom:30px;background:rgba(153,247,200,.035)}.guided-home-band h2{font-size:1.55rem;margin:9px 0}.guided-home-band p{color:rgba(255,255,255,.67);margin:0;max-width:660px;line-height:1.6}.developer-details{margin-top:14px;width:100%;border:1px solid rgba(255,255,255,.14);border-radius:13px;overflow:hidden}.developer-details summary{cursor:pointer;padding:17px 20px;color:#c7ffe2;font-weight:650;list-style-position:inside}.developer-details[open] summary{border-bottom:1px solid rgba(255,255,255,.12)}.developer-details pre{margin:0;white-space:pre;overflow:auto;padding:19px;background:#080b0a;line-height:1.65;color:#c7ffe2}.developer-detail-inner{padding:12px 20px 21px}.developer-detail-inner p{max-width:960px}.inference-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.inference-summary>div{padding:13px 17px;border-radius:11px;background:rgba(255,255,255,.035)}.inference-summary dt{color:rgba(255,255,255,.6);font-size:.82rem}.inference-summary dd{margin:7px 0 0;font-weight:650}.product-main code{overflow-wrap:anywhere}@media(max-width:900px){.start-paths{grid-template-columns:1fr}.guided-home-band{flex-direction:column;align-items:flex-start}.start-paths article>a{margin-top:8px}}@media(max-width:620px){.product-topbar .primary-nav{display:flex;max-width:100%;grid-column:1/-1;overflow-x:auto;scrollbar-width:thin}.product-topbar{grid-template-columns:auto 1fr;row-gap:9px}.start-paths article,.guided-panel{padding:19px}.guided-home-band{padding:18px!important}.developer-details summary{padding:14px}} diff --git a/scripts/qualify/assert-test-execution.js b/scripts/qualify/assert-test-execution.js new file mode 100644 index 00000000..f14daf61 --- /dev/null +++ b/scripts/qualify/assert-test-execution.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; + +// An exit-code-zero TAP stream can contain ZERO executed tests, or only SKIPs. +// Neither is a PostgreSQL qualification. The CI gate treats absent, malformed, +// contradictory, omitted, or incomplete summaries as NOT_EXECUTED. +export function assertExecutedTap(tap, { minimumTests = 1 } = {}) { + if (typeof tap !== 'string' || !Number.isSafeInteger(minimumTests) || minimumTests < 1) { + throw new TypeError('A TAP transcript and positive minimum test count are required'); + } + const lines = tap.replace(/\r\n/g, '\n').split('\n'); + if (!lines.some(line => /^TAP version 13\s*$/.test(line))) { + throw new Error('Node TAP v13 transcript header missing'); + } + const fields = ['tests', 'pass', 'fail', 'cancelled', 'skipped', 'todo']; + const summary = {}; + for (const name of fields) { + const entries = lines.filter(line => new RegExp('^# ' + name + ' [0-9]+\\s*$').test(line)); + if (entries.length !== 1) throw new Error('TAP summary missing or ambiguous: ' + name); + summary[name] = Number(entries[0].match(/\d+/)[0]); + if (!Number.isSafeInteger(summary[name])) throw new Error('TAP counter not a safe integer: ' + name); + } + if (summary.tests < minimumTests) throw new Error('Insufficient executed tests: ' + summary.tests + ' < ' + minimumTests); + if (summary.pass !== summary.tests || summary.fail !== 0 || summary.cancelled !== 0 + || summary.skipped !== 0 || summary.todo !== 0) { + throw new Error('TAP contains failed, cancelled, skipped, TODO, or unaccounted test cases'); + } + if (!lines.some(line => /^ok \d+ - /.test(line))) throw new Error('No completed TAP test entries'); + if (lines.some(line => /^not ok \d+ - /.test(line))) throw new Error('TAP reports failure despite clean summary'); + return Object.freeze({ status: 'EXECUTED_ALL_PASS', ...summary }); +} + +if (process.argv[1] && /(?:^|[/\\])assert-test-execution\.js$/.test(process.argv[1])) { + try { + if (process.argv.length !== 4) throw new Error('Usage: node scripts/qualify/assert-test-execution.js FILE MINIMUM_TESTS'); + const minimumTests = Number(process.argv[3]); + const result = assertExecutedTap(readFileSync(process.argv[2], 'utf8'), { minimumTests }); + console.log(JSON.stringify(result)); + } catch (error) { + console.error('POSTGRES_TEST_NOT_EXECUTED: ' + error.message); + process.exitCode = 1; + } +} diff --git a/scripts/qualify/external-ci.sh b/scripts/qualify/external-ci.sh index 4f526b8c..401e2623 100644 --- a/scripts/qualify/external-ci.sh +++ b/scripts/qualify/external-ci.sh @@ -72,8 +72,10 @@ pg_tests=(tests/conformance/postgres*.test.js) redteam_tests=(tests/industrial-postgres-*.test.js tests/industrial-redteam.test.js) (( ${#pg_tests[@]} > 0 )) || fail 'no PostgreSQL conformance tests found' (( ${#redteam_tests[@]} >= 2 )) || fail 'no complete PostgreSQL red-team tests found' -run_gate postgres-conformance node --test --test-concurrency=1 "${pg_tests[@]}" -run_gate postgres-redteam node --test --test-concurrency=1 "${redteam_tests[@]}" +run_gate postgres-conformance node --test --test-reporter=tap --test-concurrency=1 "${pg_tests[@]}" +run_gate postgres-conformance-executed node scripts/qualify/assert-test-execution.js "$evidence/postgres-conformance.log" 10 +run_gate postgres-redteam node --test --test-reporter=tap --test-concurrency=1 "${redteam_tests[@]}" +run_gate postgres-redteam-executed node scripts/qualify/assert-test-execution.js "$evidence/postgres-redteam.log" 4 run_gate postgres-materialization node scripts/qualify-postgres-materialization.js run_gate postgres-qualification node scripts/qualify/industrial-postgres.js --profile CI --output "$evidence/postgres" run_gate evidence-verification node scripts/qualify/verify-evidence.js "$evidence/postgres" diff --git a/scripts/qualify/postgres/cdc-capture-proof.js b/scripts/qualify/postgres/cdc-capture-proof.js new file mode 100644 index 00000000..92a29341 --- /dev/null +++ b/scripts/qualify/postgres/cdc-capture-proof.js @@ -0,0 +1,63 @@ +// Validate the FULL pgoutput capture before it is counted as qualification +// evidence. PostgreSQL row-record LSNs can predate the most recent commit +// because concurrent transactions commit in a different order. Only complete +// COMMIT positions are valid restart/order proofs. +const LSN = /^[0-9A-Fa-f]{1,8}\/[0-9A-Fa-f]{1,8}$/; +const ROW_TAGS = new Set(['insert', 'update', 'delete', 'truncate']); + +function bad(code, message) { + throw Object.assign(new Error(message), { code }); +} +export function compareCaptureLsn(left, right) { + for (const lsn of [left, right]) { + if (typeof lsn !== 'string' || !LSN.test(lsn)) bad('INVALID_POSTGRES_LSN', 'Invalid pgoutput LSN'); + } + const decode = value => { + const [hi, lo] = value.split('/'); + return (BigInt('0x' + hi) << 32n) | BigInt('0x' + lo); + }; + const a = decode(left), b = decode(right); + return a < b ? -1 : a > b ? 1 : 0; +} + +export function auditCdcCapture(changes, { priorCheckpointLsn = null, requireCommit = true } = {}) { + if (!Array.isArray(changes)) bad('CDC_CAPTURE_INVALID', 'CDC capture must be an array'); + if (priorCheckpointLsn != null) compareCaptureLsn(priorCheckpointLsn, priorCheckpointLsn); + let checkpointLsn = priorCheckpointLsn; + let inTx = false; + let lastRowLsn = null; + let commits = 0, rowEvents = 0; + for (const change of changes) { + if (!change || typeof change !== 'object') bad('CDC_CAPTURE_INVALID', 'Null or malformed CDC event'); + if (change.tag === 'begin') { + if (inTx) bad('CDC_CAPTURE_INCOMPLETE', 'Nested CDC transaction BEGIN'); + inTx = true; + lastRowLsn = null; + } else if (change.tag === 'commit') { + if (!inTx) bad('CDC_CAPTURE_INCOMPLETE', 'CDC COMMIT without BEGIN'); + const commitLsn = change.commitLsn ?? change.lsn; + compareCaptureLsn(commitLsn, commitLsn); + if (checkpointLsn && compareCaptureLsn(commitLsn, checkpointLsn) < 0) { + bad('CDC_RESTART_REPLAY', 'CDC commit regressed below durable checkpoint'); + } + const commitEndLsn = change.commitEndLsn ?? commitLsn; + if (compareCaptureLsn(commitEndLsn, commitLsn) < 0) { + bad('CDC_CAPTURE_INVALID', 'CDC COMMIT end precedes commit record'); + } + checkpointLsn = commitEndLsn; + commits += 1; + inTx = false; + } else if (ROW_TAGS.has(change.tag)) { + if (!inTx) bad('CDC_CAPTURE_INCOMPLETE', 'CDC row mutation outside a transaction'); + compareCaptureLsn(change.lsn, change.lsn); + if (lastRowLsn && compareCaptureLsn(change.lsn, lastRowLsn) < 0) { + bad('CDC_STREAM_ORDER_VIOLATION', 'CDC row records regressed within a transaction'); + } + lastRowLsn = change.lsn; + rowEvents += 1; + } + } + if (inTx) bad('CDC_CAPTURE_INCOMPLETE', 'CDC capture ended within an open transaction'); + if (requireCommit && commits === 0) bad('CDC_CAPTURE_INCOMPLETE', 'CDC capture has no complete transaction boundary'); + return Object.freeze({ checkpointLsn, commits, rowEvents }); +} diff --git a/scripts/qualify/postgres/cdc-production-lifecycle.js b/scripts/qualify/postgres/cdc-production-lifecycle.js index ffd0ee85..5fddcd61 100644 --- a/scripts/qualify/postgres/cdc-production-lifecycle.js +++ b/scripts/qualify/postgres/cdc-production-lifecycle.js @@ -73,17 +73,27 @@ export async function runCdcProductionLifecycle({ profile, seed, evidenceWriter, while (Date.now() < stopAt) { const kind = (wi * 13 + opIndex * 7) % 100; const baseId = rowCount + wi * 2_000_000 + opIndex; - if (kind < 60) { + // A logical writer operation is exactly one source transaction. The + // delete+insert branch must not expose an intermediate deleted row to + // logical decoding. Do not claim "transactions" by counting loops. + await client.query('BEGIN'); + try { + if (kind < 60) { await client.query(`INSERT INTO public."${sourceTable}"(id, payload) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, [baseId + 1, `w${wi}-${opIndex}`]); - } else if (kind < 85) { + } else if (kind < 85) { await client.query(`UPDATE public."${sourceTable}" SET payload = $2 WHERE id = $1`, [(opIndex % rowCount) + 1, `u${wi}-${opIndex}`]); - } else { + } else { await client.query(`DELETE FROM public."${sourceTable}" WHERE id = $1`, [(opIndex % rowCount) + 1]); await client.query(`INSERT INTO public."${sourceTable}"(id, payload) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, [(opIndex % rowCount) + 1, `r${wi}-${opIndex}`]); + } + await client.query('COMMIT'); + transactions += 1; + } catch (error) { + try { await client.query('ROLLBACK'); } catch { /* original failure wins */ } + throw error; } ops += 1; opIndex += 1; if (opIndex % 25 === 0) { - transactions += 1; await new Promise(resolve => setTimeout(resolve, 5)); } } @@ -170,7 +180,11 @@ export async function runCdcProductionLifecycle({ profile, seed, evidenceWriter, const writerStats = await writerPromise; const totalOps = writerStats.reduce((a, b) => a + b.ops, 0); - evidenceWriter.appendRaw({ scenario, event: 'writers_quiesced', totalOps, writers }); + const committedSourceTransactions = writerStats.reduce((a, b) => a + b.transactions, 0); + if (committedSourceTransactions !== totalOps) { + throw new Error(`Qualification writer transactions do not match committed operations: ${committedSourceTransactions}/${totalOps}`); + } + evidenceWriter.appendRaw({ scenario, event: 'writers_quiesced', totalOps, committedSourceTransactions, writers }); // 4. Catch up through the waterline on the production applier path. const quencher = await admin(); @@ -240,7 +254,7 @@ export async function runCdcProductionLifecycle({ profile, seed, evidenceWriter, scenario, requestedConfig: { rows: profile.rows, writers: profile.writers, cdcDurationSeconds: profile.cdcDurationSeconds, largeTransactionRows: profile.largeTransactionRows }, effectiveConfig, - rowCount, writers, durationSec, largeTxRows, totalOps, + rowCount, writers, durationSec, largeTxRows, totalOps, committedSourceTransactions, appliedTransactions: summary.appliedTransactions ?? null, appliedLsn: binding?.appliedLsn ?? null, acknowledgedLsn: binding?.acknowledgedLsn ?? null, diff --git a/scripts/qualify/postgres/cdc-soak.js b/scripts/qualify/postgres/cdc-soak.js index b25abb91..686cb9db 100644 --- a/scripts/qualify/postgres/cdc-soak.js +++ b/scripts/qualify/postgres/cdc-soak.js @@ -5,13 +5,13 @@ import { join } from 'node:path'; import { Client } from 'pg'; import { CredentialBroker } from '../../../src/daemon/credential-broker.js'; import { - comparePostgresLsn, createCdcCheckpointStore, createPostgresReplicationSlot, dropPostgresReplicationSlot, ensurePostgresPublication, streamPostgresChanges } from '../../../src/connectors/postgres/cdc.js'; +import { auditCdcCapture } from './cdc-capture-proof.js'; function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); @@ -28,7 +28,12 @@ function opMix(writer, opIndex) { } function opEvents(kind) { - return kind === 'multi' ? 2 : 1; + // UPDATE/DELETE emit their seed INSERT plus mutation. MULTI must + // insert two DIFFERENT primary keys in a single committed transaction. + // All three cases therefore emit exactly two row records. + if (kind === 'insert') return 1; + if (kind === 'update' || kind === 'delete' || kind === 'multi') return 2; + throw new Error(`Unsupported deterministic CDC operation: ${kind}`); } export async function runCdcSoak({ profile, seed, evidenceWriter, pgConfig, runId }) { @@ -62,107 +67,101 @@ export async function runCdcSoak({ profile, seed, evidenceWriter, pgConfig, runI return res.rows[0].lsn; }; - const CHUNKS = Math.max(4, Math.min(12, Math.floor(durationSec / 3))); - const opsPerChunk = Math.max(4, Math.floor((durationSec * 1000) / (30 * CHUNKS))); + // Each 30-second slice allocates at most 100 operations per writer. A + // seven-day run cannot allocate billions of planned operations upfront. + const CHUNK_WINDOW_MS = 30_000; + const CHUNKS = Math.max(1, Math.ceil(durationSec * 1000 / CHUNK_WINDOW_MS)); + const opsPerChunk = 100; const writerPaceMs = Math.max(1, Math.floor((durationSec * 1000) / (CHUNKS * opsPerChunk))); + const WRITER_BASE = 100_000_000, WRITER_STRIDE = 20_000_000, MULTI_OFFSET = 10_000_000; - // Deterministic per-chunk plan: op indices advance globally so capture order is stable. - // update/delete ops may target rows that do not exist yet and emit no change message, - // so equality is asserted on unique insert captures only; all captured changes still - // carry monotonicity and resume-bound checks below. - const plans = []; + // Only count SQL operations and inserts once the exact effect completed. let expectedInsertCount = 0; let eventsAttempted = 0; - for (let chunk = 0; chunk < CHUNKS; chunk += 1) { - const chunkPlans = Array.from({ length: writers }, () => []); - for (let wi = 0; wi < writers; wi += 1) { - for (let op = 0; op < opsPerChunk; op += 1) { - const opIndex = chunk * opsPerChunk * writers + wi * opsPerChunk + op; - const kind = opMix(wi, opIndex); - eventsAttempted += opEvents(kind); - if (kind === 'insert') expectedInsertCount += 1; - chunkPlans[wi].push({ opIndex, kind }); - } - } - plans.push(chunkPlans); - } let capturedChanges = 0; let insertCapturedTotal = 0; let currentCheckpoint = null; - let streamOrdered = true; - let strictResume = true; + // auditCdcCapture throws on either violation; both labels are only emitted + // after every chunk and restart has passed the same complete-tx audit. + const streamOrdered = true; + const strictResume = true; let tailEvents = 0; let tailCaptured = 0; let windowBounded = true; let lastLagBytes = null; - async function writePlan(plan) { - const tasks = Array.from({ length: writers }, async (_, wi) => { - const wClient = new Client({ host: pgConfig.host, port: pgConfig.port, database: pgConfig.database, user: pgConfig.user, password: pgConfig.password, ssl: false }); - await wClient.connect(); + async function writePlan(chunk) { + const jobs = Array.from({ length: writers }, async (_, wi) => { + const client = new Client({ host: pgConfig.host, port: pgConfig.port, database: pgConfig.database, user: pgConfig.user, password: pgConfig.password, ssl: false }); + await client.connect(); + let inserted = 0, attempted = 0, operations = 0; try { - const ops = plan[wi]; - for (let i = 0; i < ops.length; i += 1) { - const { opIndex, kind } = ops[i]; - const baseId = wi * 1_000_000 + opIndex; + for (let op = 0; op < opsPerChunk; op += 1) { + const opIndex = chunk * opsPerChunk + op; + const kind = opMix(wi, opIndex); + const singleId = WRITER_BASE + wi * WRITER_STRIDE + opIndex + 1; + const secondId = singleId + MULTI_OFFSET; + if (opIndex >= MULTI_OFFSET || secondId > 2_147_483_647) { + throw new Error('CDC writer exhausted its disjoint 32-bit identifier band'); + } + let inTx = false; try { - if (kind === 'insert') { - await wClient.query(`INSERT INTO public."${table}"(id, payload) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, [baseId + 1, `payload-${opIndex}`]); + if (kind !== 'insert') { await client.query('BEGIN'); inTx = true; } + const added = await client.query(`INSERT INTO public."${table}"(id, payload) VALUES ($1, $2)`, [singleId, `new-${wi}-${opIndex}`]); + if (added.rowCount !== 1) throw new Error('CDC writer insert was not exactly one row'); + // These are real committed DML effects, not UPDATE/DELETE no-ops + // on missing rows. MULTI uses two distinct primary keys so it + // exercises a genuine multi-row source transaction. + if (kind === 'multi') { + const second = await client.query(`INSERT INTO public."${table}"(id, payload) VALUES ($1, $2)`, [secondId, `second-${wi}-${opIndex}`]); + if (second.rowCount !== 1) throw new Error('CDC multi-row second insert was not exactly one row'); } else if (kind === 'update') { - await wClient.query(`UPDATE public."${table}" SET payload = $2 WHERE id = $1`, [baseId % 200 + 1, `updated-${opIndex}`]); + const updated = await client.query(`UPDATE public."${table}" SET payload = $2 WHERE id = $1`, [singleId, `updated-${wi}-${opIndex}`]); + if (updated.rowCount !== 1) throw new Error('CDC writer update was not exactly one row'); } else if (kind === 'delete') { - await wClient.query(`DELETE FROM public."${table}" WHERE id = $1`, [baseId % 200 + 1]); - } else { - const multiId = baseId + 100_000; - await wClient.query(`BEGIN; INSERT INTO public."${table}"(id, payload) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING; UPDATE public."${table}" SET payload = $3 WHERE id = $4; COMMIT`, [multiId, `m-${opIndex}`, `mu-${opIndex}`, multiId % 200 + 1]); + const deleted = await client.query(`DELETE FROM public."${table}" WHERE id = $1`, [singleId]); + if (deleted.rowCount !== 1) throw new Error('CDC writer delete was not exactly one row'); + } + if (inTx) await client.query('COMMIT'); + inserted += kind === 'multi' ? 2 : 1; + attempted += opEvents(kind); + operations += 1; + } catch (error) { + if (inTx) { + try { await client.query('ROLLBACK'); } catch { /* original failure wins */ } } - } catch {} - if (i < ops.length - 1) await sleep(writerPaceMs); + throw error; + } + if (op < opsPerChunk - 1) await sleep(writerPaceMs); } } finally { - await wClient.end(); + await client.end(); } + return { inserted, attempted, operations }; }); - await Promise.all(tasks); - } - - // Streaming-ordering invariant: pgoutput emits complete transactions in commit - // order, and row changes within one transaction are ordered by their WAL record. - // Two concurrent writers therefore do NOT imply globally monotonic row LSNs in - // emission order — a later transaction may carry earlier row-LSNs than a previous - // transaction's commit. What is guaranteed (and what we assert): - // (a) commit LSNs are non-decreasing across the stream; - // (b) row LSNs within each transaction are non-decreasing. - function recordStream(changes) { - let lastCommit = null; - let inTx = false; - let txLastRow = null; - for (const change of changes) { - if (change.tag === 'begin') { inTx = true; txLastRow = null; continue; } - if (change.tag === 'commit') { - if (lastCommit && comparePostgresLsn(change.lsn, lastCommit) < 0) streamOrdered = false; - lastCommit = change.lsn; - inTx = false; - continue; - } - if (inTx && ['insert', 'update', 'delete'].includes(change.tag)) { - if (txLastRow && comparePostgresLsn(change.lsn, txLastRow) < 0) streamOrdered = false; - txLastRow = change.lsn; - } + // Every writer must close before a failed run may drop the test tables. + const completed = await Promise.allSettled(jobs); + const rejected = completed.find(outcome => outcome.status === 'rejected'); + if (rejected) throw rejected.reason; + const result = completed.reduce((acc, outcome) => { + acc.inserted += outcome.value.inserted; + acc.attempted += outcome.value.attempted; + acc.operations += outcome.value.operations; + return acc; + }, { inserted: 0, attempted: 0, operations: 0 }); + if (result.operations !== writers * opsPerChunk) { + throw new Error('CDC workload did not execute every required writer operation'); } + return result; } - function checkResumeBound(rowChanges, priorCheckpoint) { - if (!priorCheckpoint) return; - for (const change of rowChanges) { - if (comparePostgresLsn(change.lsn, priorCheckpoint) < 0) strictResume = false; - } - } - - const lastCheckpointLsn = changes => { - const commits = changes.filter(change => change.tag === 'commit'); - return commits.length ? commits[commits.length - 1].lsn : changes.length ? changes[changes.length - 1].lsn : null; + // Audit every complete pgoutput transaction against the PREVIOUS durable + // end-of-commit cursor. Source row LSNs may legitimately predate that + // cursor for transactions that started earlier and committed later. + const capture = (result, cursor) => { + if (result.truncatedChanges) throw new Error('CDC capture truncated its bounded event buffer'); + return auditCdcCapture(result.changes, { priorCheckpointLsn: cursor }); }; try { @@ -184,26 +183,35 @@ export async function runCdcSoak({ profile, seed, evidenceWriter, pgConfig, runI // Steady-state capture: each chunk writes its deterministic plan, then the real // SPOOL CDC connector streams from the durable cursor to the chunk waterline. + const soakStart = process.hrtime.bigint(); for (let chunk = 0; chunk < CHUNKS; chunk += 1) { const chunkStart = Date.now(); - await writePlan(plans[chunk]); + const applied = await writePlan(chunk); + expectedInsertCount += applied.inserted; + eventsAttempted += applied.attempted; // Flush anchor: a deterministic insert that is the last WAL record of the chunk, // guaranteeing the streaming stop condition observes a commit at the waterline. const anchorId = 5_000_000 + chunk; - await adminClient.query(`INSERT INTO public."${table}"(id, payload) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, [anchorId, `anchor-${chunk}`]); + const anchored = await adminClient.query(`INSERT INTO public."${table}"(id, payload) VALUES ($1, $2)`, [anchorId, `anchor-${chunk}`]); + if (anchored.rowCount !== 1) throw new Error('CDC anchor was not inserted exactly once'); expectedInsertCount += 1; eventsAttempted += 1; const untilLsn = await flushLsn(adminClient); const streamed = await streamPostgresChanges({ ref: dbRef, credentialBroker: broker, slotName, publicationNames: [publicationName], - startLsn: currentCheckpoint, untilLsn, maxMessages: 1_000_000 + startLsn: currentCheckpoint, untilLsn, maxMessages: 1_000_000, + maxCollectedChanges: 100_000 }); + const chunkAudit = capture(streamed, currentCheckpoint); const rowChanges = streamed.changes.filter(change => ['insert', 'update', 'delete'].includes(change.tag) && change.table === table); + if (rowChanges.length !== applied.attempted + 1) { + failures.push({ code: 'CDC_ACTUAL_EVENT_COUNT_MISMATCH', + message: `chunk ${chunk} captured ${rowChanges.length} row events; expected ${applied.attempted + 1}`, + expected: false, classification: 'CORRECTNESS' }); + } capturedChanges += rowChanges.length; insertCapturedTotal += rowChanges.filter(change => change.tag === 'insert').length; - checkResumeBound(rowChanges, currentCheckpoint); - recordStream(streamed.changes); - currentCheckpoint = lastCheckpointLsn(streamed.changes) ?? currentCheckpoint; + currentCheckpoint = chunkAudit.checkpointLsn; store.saveCheckpoint(slotName, currentCheckpoint); evidenceWriter.appendRaw({ scenario, event: 'cdc_chunk_captured', chunk, rowChanges: rowChanges.length, messages: streamed.changes.length, untilLsn, checkpoint: currentCheckpoint, streamOrdered, strictResume }); @@ -211,6 +219,16 @@ export async function runCdcSoak({ profile, seed, evidenceWriter, pgConfig, runI if (elapsed < writerPaceMs * opsPerChunk) await sleep(writerPaceMs * opsPerChunk - elapsed); } + // Coarse timer rounding and SQL/stream scheduling must not produce a + // systematic false failure for the nominal 30s CI profile. End only + // once the monotonic clock proves the requested continuous interval. + const remainingSoakMs = durationSec * 1000 - Number(process.hrtime.bigint() - soakStart) / 1e6; + if (remainingSoakMs > 0) await sleep(Math.ceil(remainingSoakMs)); + const observedSoakMs = Number(process.hrtime.bigint() - soakStart) / 1e6; + if (observedSoakMs < durationSec * 1000) { + failures.push({ code: 'CDC_SOAK_UNDER_EXECUTED', message: 'Monotonic CDC duration was shorter than declared workload', expected: false, classification: 'HARNESS' }); + } + // Concurrent-window proof: a capture whose waterline was fixed BEFORE a burst // commits must not see the burst (window bounded), and a fresh restart from that // capture's durable cursor must absorb the burst exactly once (no loss, no replay). @@ -226,7 +244,8 @@ export async function runCdcSoak({ profile, seed, evidenceWriter, pgConfig, runI const streamPromise = streamPostgresChanges({ ref: dbRef, credentialBroker: broker, slotName, publicationNames: [publicationName], - startLsn: currentCheckpoint, untilLsn: waterline, maxMessages: 1_000_000 + startLsn: currentCheckpoint, untilLsn: waterline, maxMessages: 1_000_000, + maxCollectedChanges: 100_000 }); const burstPromise = (async () => { try { @@ -241,6 +260,7 @@ export async function runCdcSoak({ profile, seed, evidenceWriter, pgConfig, runI })(); const [windowResult, burstResult] = await Promise.all([streamPromise, burstPromise]); + const windowAudit = capture(windowResult, currentCheckpoint); const windowRows = windowResult.changes.filter(change => ['insert', 'update', 'delete'].includes(change.tag) && change.table === table); if (windowRows.some(change => change.row?.id >= 9_000_000)) windowBounded = false; // The in-flight capture stops at the marker: it must contain exactly the window row. @@ -249,21 +269,21 @@ export async function runCdcSoak({ profile, seed, evidenceWriter, pgConfig, runI } tailCaptured += windowRows.length; insertCapturedTotal += windowRows.filter(change => change.tag === 'insert').length; - currentCheckpoint = lastCheckpointLsn(windowResult.changes) ?? currentCheckpoint; + currentCheckpoint = windowAudit.checkpointLsn; store.saveCheckpoint(slotName, currentCheckpoint); const tailUntil = await flushLsn(adminClient); const resume = await streamPostgresChanges({ ref: dbRef, credentialBroker: broker, slotName, publicationNames: [publicationName], - startLsn: currentCheckpoint, untilLsn: tailUntil, maxMessages: 1_000_000 + startLsn: currentCheckpoint, untilLsn: tailUntil, maxMessages: 1_000_000, + maxCollectedChanges: 100_000 }); + const resumeAudit = capture(resume, currentCheckpoint); const resumeRows = resume.changes.filter(change => ['insert', 'update', 'delete'].includes(change.tag) && change.table === table); - checkResumeBound(resumeRows, currentCheckpoint); - recordStream(resume.changes); if (resumeRows.some(change => change.row?.id < 9_000_000)) windowBounded = false; tailCaptured += resumeRows.length; insertCapturedTotal += resumeRows.filter(change => change.tag === 'insert').length; - currentCheckpoint = lastCheckpointLsn(resume.changes) ?? currentCheckpoint; + currentCheckpoint = resumeAudit.checkpointLsn; store.saveCheckpoint(slotName, currentCheckpoint); evidenceWriter.appendRaw({ scenario, event: 'cdc_cursor_restart', checkpoint: currentCheckpoint, tailEvents, tailCaptured, windowBounded, @@ -300,12 +320,6 @@ export async function runCdcSoak({ profile, seed, evidenceWriter, pgConfig, runI failures.push({ code: 'SLOT_LOSS_FAIL_CLOSED_VIOLATED', message: 'streaming continued after the replication slot was dropped', expected: false, classification: 'CORRECTNESS' }); } - if (!strictResume) { - failures.push({ code: 'CDC_RESTART_REPLAY', message: 'resumed stream replayed rows below the durable checkpoint', expected: false, classification: 'CORRECTNESS' }); - } - if (!streamOrdered) { - failures.push({ code: 'CDC_STREAM_ORDER_VIOLATION', message: 'change stream violated commit or per-transaction ordering', expected: false, classification: 'CORRECTNESS' }); - } if (insertCapturedTotal !== expectedInsertCount) { failures.push({ code: 'CDC_INSERT_COUNT_MISMATCH', message: `captured ${insertCapturedTotal} insert changes, expected ${expectedInsertCount}`, expected: false, classification: 'CORRECTNESS' }); } @@ -320,6 +334,7 @@ export async function runCdcSoak({ profile, seed, evidenceWriter, pgConfig, runI requestedConfig: { cdcDurationSeconds: profile.cdcDurationSeconds, writers: profile.writers }, effectiveConfig, durationSec, + observedSoakMs, writers, chunks: CHUNKS, eventsAttempted, diff --git a/scripts/qualify/postgres/crash-child.js b/scripts/qualify/postgres/crash-child.js index 0c8c3e1c..9ee03eef 100644 --- a/scripts/qualify/postgres/crash-child.js +++ b/scripts/qualify/postgres/crash-child.js @@ -11,6 +11,7 @@ import { CredentialBroker } from '../../../src/daemon/credential-broker.js'; import { createPostgresTargetRuntime } from '../../../src/connectors/postgres/runtime.js'; import { MigrationRunner } from '../../../src/execution/migration-runner.js'; import { generateRow } from '../dataset.js'; +import { createDurableJsonCheckpointStore } from './durable-checkpoint.js'; function parseArgs() { const idx = process.argv.indexOf('--config'); @@ -47,27 +48,10 @@ async function main() { const lease = await execution.renewLease(); const target = execution.openTarget(); - // File-backed checkpoint store: parent shares the same file path via cfg.checkpointPath - // We use a simple JSON file checkpoint for this child->parent proof (durable across kill). - // The parent will read same file after kill to verify checkpoint persistence. - const { checkpointPath } = cfg; - const checkpointStore = { - _path: checkpointPath, - async load() { - try { - const data = await readFile(checkpointPath, 'utf8'); - return JSON.parse(data); - } catch { - return null; - } - }, - async save(cp) { - const { writeFile, mkdir } = await import('node:fs/promises'); - const { dirname } = await import('node:path'); - await mkdir(dirname(checkpointPath), { recursive: true }); - await writeFile(checkpointPath, JSON.stringify(cp), 'utf8'); - } - }; + // The parent and killed child share one atomic, fsync'd checkpoint file. + // SIGKILL can expose either the old complete JSON or the new complete JSON, + // never a torn in-place write silently interpreted as no checkpoint. + const checkpointStore = createDurableJsonCheckpointStore({ path: cfg.checkpointPath }); const runner = new MigrationRunner({ target, checkpointStore }); diff --git a/scripts/qualify/postgres/crash-recovery.js b/scripts/qualify/postgres/crash-recovery.js index d8182e47..820624ee 100644 --- a/scripts/qualify/postgres/crash-recovery.js +++ b/scripts/qualify/postgres/crash-recovery.js @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { spawn } from 'node:child_process'; @@ -10,6 +10,7 @@ import { CredentialBroker } from '../../../src/daemon/credential-broker.js'; import { SpoolCommandService } from '../../../src/daemon/command-service.js'; import { writeDatasetCsv } from '../dataset.js'; import { verifyRecoveredTargetContent } from '../recovery-content.js'; +import { createDurableJsonCheckpointStore } from './durable-checkpoint.js'; // PostgreSQL truncates identifiers past 63 characters: all scenario table @@ -130,8 +131,13 @@ export async function runCrashRecovery({ profile, seed, evidenceWriter, pgConfig // qualification-only TTL keeps the test bounded without admin DELETE. const killLeaseTtlMs = 8_000; const killSeed = `${seed}-crash-kill`; - const checkpointPath = join(tmpdir(), `spool-qualify-checkpoint-${runId}.json`); - const childConfigPath = join(tmpdir(), `spool-qualify-child-${runId}.json`); + // A private directory contains both the child config (DB password) + // and atomic-checkpoint temp files left by SIGKILL. Removing the + // directory after the fault clears all orphaned files as well. + const crashDir = await mkdtemp(join(tmpdir(), 'spool-qualify-crash-')); + const checkpointPath = join(crashDir, 'checkpoint.json'); + const durableCheckpoint = createDurableJsonCheckpointStore({ path: checkpointPath }); + const childConfigPath = join(crashDir, 'child.json'); let killChild = null; let killFaultAt = null; let ledgerBeforeKill = 0; @@ -224,10 +230,10 @@ export async function runCrashRecovery({ profile, seed, evidenceWriter, pgConfig await new Promise(r => setTimeout(r, 5)); } ledgerBeforeKill = ledgerCount; - try { - const cpRaw = await readFile(checkpointPath, 'utf8'); - checkpointBeforeKill = JSON.parse(cpRaw); - } catch { checkpointBeforeKill = null; } + // Atomic checkpoint reader: only ENOENT means none. A truncated, + // corrupt, or inaccessible file fails the fault proof rather than + // hiding a durability defect behind a full replay. + checkpointBeforeKill = await durableCheckpoint.load(); evidenceWriter.appendRaw({ scenario, event: 'crash_kill_ledger_observed', ledgerCount, checkpoint: checkpointBeforeKill, pid: killChild.pid }); // The fault is valid only when an actual SIGKILL kills a still-live @@ -349,12 +355,7 @@ export async function runCrashRecovery({ profile, seed, evidenceWriter, pgConfig const { MigrationRunner: Runner2 } = await import('../../../src/execution/migration-runner.js'); const { generateRow: genRow } = await import('../dataset.js'); const target2 = exec2.openTarget(); - const cpStore2 = { - async load() { - try { return JSON.parse(await readFile(checkpointPath, 'utf8')); } catch { return null; } - }, - async save(cp) { await writeFile(checkpointPath, JSON.stringify(cp), 'utf8'); } - }; + const cpStore2 = durableCheckpoint; const runner2 = new Runner2({ target: target2, checkpointStore: cpStore2 }); let checkpointAfter = await cpStore2.load(); let recoveredBatches = 0; @@ -425,8 +426,7 @@ export async function runCrashRecovery({ profile, seed, evidenceWriter, pgConfig evidenceWriter.appendRaw({ scenario, event: 'crash_kill_failed', error: e.message, code: e.code ?? 'UNKNOWN' }); } finally { try { if (killChild && killChild.exitCode == null) killChild.kill('SIGKILL'); } catch {} - try { await rm(childConfigPath, { force: true }); } catch {} - try { await rm(checkpointPath, { force: true }); } catch {} + try { await rm(crashDir, { recursive: true, force: true }); } catch {} try { const c = new Client({ host: pgConfig.host, port: pgConfig.port, database: pgConfig.database, user: pgConfig.user, password: pgConfig.password, ssl: false }); await c.connect(); diff --git a/scripts/qualify/postgres/database-source-load.js b/scripts/qualify/postgres/database-source-load.js index 0de7906c..6c271ff5 100644 --- a/scripts/qualify/postgres/database-source-load.js +++ b/scripts/qualify/postgres/database-source-load.js @@ -37,40 +37,67 @@ export async function runDatabaseSourceLoad({ profile, seed, evidenceWriter, pgC const catalog = await discoverPostgresCatalog({ ref, credentialBroker: broker, schemas: ['public'] }); evidenceWriter.appendRaw({ scenario, event: 'catalog_discovered', tableCount: catalog.tableCount, catalogId: catalog.catalogId }); - // Export snapshot AND read inside the same owning transaction. - // The exported token is only joinable while the exporter holds its tx open, - // so all consistency reads must happen inside the callback. - const snapshotResult = await withExportedPostgresSnapshot({ ref, credentialBroker: broker }, async (snapshot) => { - evidenceWriter.appendRaw({ scenario, event: 'snapshot_exported', exportedSnapshotId: snapshot.exportedSnapshotId, walLsn: snapshot.walLsn }); - // Read two tables via the shared MVCC snapshot by joining inside separate - // sessions while the exporter is still open (exporter commits on return). - // NOTE: joinPostgresSnapshot opens its own BEGIN; the token is valid here. + // The exporter must remain open while all child sessions join its MVCC + // snapshot. A write performed AFTER the snapshot closes proves nothing. + const snapshotResult = await withExportedPostgresSnapshot({ ref, credentialBroker: broker }, async snapshot => { + evidenceWriter.appendRaw({ scenario, event: 'snapshot_exported', + exportedSnapshotId: snapshot.exportedSnapshotId, walLsn: snapshot.walLsn }); + + // An INDEPENDENT admin session commits post-export mutations BEFORE + // any child reader joins. Every live table now has 1001 rows while + // every reader joined to the old snapshot must still see 1000. + for (const t of tables) { + await admin.query(`INSERT INTO public."${t}"(id, payload) VALUES (1000001, 'post-snapshot')`); + } + + // Exactly profile.writers concurrent sessions, each joining two tables. + // The old harness created 2*writers tables but silently read only two. const runtime = createPostgresSourceRuntime({ credentialBroker: broker, batchSize: 1000 }); - let totalRows = 0; - const readTables = tables.slice(0, 2); - for (const t of readTables) { - const sourceRef = { connector: 'postgres', endpoint: `postgresql://${pgConfig.host}:${pgConfig.port}?sslmode=disable`, database: pgConfig.database, schema: 'public', table: t, identity: { user: pgConfig.user }, secretRef: { provider: 'env', key: 'PGPASSWORD' } }; - const count = await runtime.withSnapshot(sourceRef, async (s) => { - let c = 0; - for await (const b of s.batches()) c += b.rows.length; - return c; - }, { snapshotToken: snapshot.exportedSnapshotId }); - totalRows += count; + const readerStats = await Promise.all(Array.from({ length: profile.writers }, async (_, wi) => { + let readRows = 0; + for (const t of tables.slice(wi * 2, wi * 2 + 2)) { + const sourceRef = { + connector: 'postgres', endpoint: ref.endpoint, database: pgConfig.database, + schema: 'public', table: t, identity: { user: pgConfig.user }, + secretRef: { provider: 'env', key: 'PGPASSWORD' } + }; + const count = await runtime.withSnapshot(sourceRef, async joined => { + let c = 0; + for await (const batch of joined.batches()) c += batch.rows.length; + return c; + }, { snapshotToken: snapshot.exportedSnapshotId }); + if (count !== 1000) { + throw new Error(`Joined reader ${wi} observed ${count} rows in ${t}; exported snapshot should contain 1000`); + } + readRows += count; + } + return { writer: wi, rows: readRows, tablesRead: 2 }; + })); + const totalRows = readerStats.reduce((total, reader) => total + reader.rows, 0); + if (readerStats.length !== profile.writers || totalRows !== tableCount * 1000) { + throw new Error('Exported-snapshot fanout did not execute its full declared reader count'); } - // Concurrent-write check: insert AFTER snapshot export; those rows must - // NOT be visible to the joined readers above (they already completed) — - // we verify by re-reading outside the snapshot and confirming divergence. - return { exportedSnapshotId: snapshot.exportedSnapshotId, walLsn: snapshot.walLsn, catalogId: catalog.catalogId, totalRows }; - }); - evidenceWriter.appendRaw({ scenario, event: 'database_source_completed', totalRows: snapshotResult.totalRows, tableCount }); + // Prove the writes were real and visible outside the exported snapshot, + // while all joined readers above saw only the pre-export rows. + for (const t of tables) { + const live = await admin.query(`SELECT count(*)::int AS c FROM public."${t}"`); + if (live.rows[0]?.c !== 1001) { + throw new Error(`Post-export write was not visible to an independent session for ${t}`); + } + } + return { + exportedSnapshotId: snapshot.exportedSnapshotId, + walLsn: snapshot.walLsn, catalogId: catalog.catalogId, totalRows, + joinedReaders: readerStats.length, + tablesRead: readerStats.reduce((total, reader) => total + reader.tablesRead, 0), + postExportWrites: tables.length + }; + }); - // Post-snapshot write: prove the snapshot was point-in-time by showing the - // live table now has rows the snapshot read did not see. - await admin.query(`INSERT INTO public."${tables[0]}"(id, payload) VALUES (1000001, 'post-snapshot')`); - const liveCount = await admin.query(`SELECT count(*)::int as c FROM public."${tables[0]}"`); - if (liveCount.rows[0].c !== 1001) throw new Error(`expected 1001 live rows after post-snapshot write, got ${liveCount.rows[0].c}`); - if (snapshotResult.totalRows !== 2000) throw new Error(`expected snapshot readers to see 2000 rows (2x1000), saw ${snapshotResult.totalRows}`); + evidenceWriter.appendRaw({ scenario, event: 'database_source_completed', + totalRows: snapshotResult.totalRows, tableCount, joinedReaders: snapshotResult.joinedReaders, + tablesRead: snapshotResult.tablesRead, postExportWrites: snapshotResult.postExportWrites }); await admin.end(); admin = null; @@ -79,7 +106,7 @@ export async function runDatabaseSourceLoad({ profile, seed, evidenceWriter, pgC for (const t of tables) await cleanup.query(`DROP TABLE IF EXISTS public."${t}" CASCADE`); await cleanup.end(); - return { metrics: { scenario, requestedConfig: { writers: profile.writers }, effectiveConfig, tableCount, totalRows: snapshotResult.totalRows, catalogId: catalog.catalogId, snapshotId: snapshotResult.exportedSnapshotId }, failures: [] }; + return { metrics: { scenario, requestedConfig: { writers: profile.writers }, effectiveConfig, tableCount, totalRows: snapshotResult.totalRows, joinedReaders: snapshotResult.joinedReaders, tablesRead: snapshotResult.tablesRead, postExportWrites: snapshotResult.postExportWrites, catalogId: catalog.catalogId, snapshotId: snapshotResult.exportedSnapshotId }, failures: [] }; } finally { try { if (admin) await admin.end(); } catch {} try { diff --git a/scripts/qualify/postgres/durable-checkpoint.js b/scripts/qualify/postgres/durable-checkpoint.js new file mode 100644 index 00000000..3804fc38 --- /dev/null +++ b/scripts/qualify/postgres/durable-checkpoint.js @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto'; +import { open, mkdir, readFile, rename, rm } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +// Durable, crash-recoverable JSON checkpoint for qualification child processes. +// File content is fsync'd before atomic rename, and the containing directory +// is fsync'd after rename. Only ENOENT means "no checkpoint"; corrupt bytes, +// IO failures and permission errors MUST NOT masquerade as a fresh start. +export function createDurableJsonCheckpointStore({ path, onBeforeRename = null } = {}) { + if (typeof path !== 'string' || !path) throw new TypeError('checkpoint path is required'); + if (onBeforeRename != null && typeof onBeforeRename !== 'function') { + throw new TypeError('onBeforeRename must be a test hook function'); + } + const dir = dirname(path); + return Object.freeze({ + async load() { + let content; + try { content = await readFile(path, 'utf8'); } + catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } + // Returning null on invalid/truncated JSON would hide a crash-safety + // failure and could make the qualification falsely succeed by replay. + const parsed = JSON.parse(content); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Durable checkpoint must be a JSON object'); + } + return parsed; + }, + async save(checkpoint) { + if (!checkpoint || typeof checkpoint !== 'object' || Array.isArray(checkpoint)) { + throw new TypeError('Checkpoint must be a JSON object'); + } + const serialized = JSON.stringify(checkpoint); + if (typeof serialized !== 'string') throw new TypeError('Checkpoint is not serializable'); + await mkdir(dir, { recursive: true }); + const temp = join(dir, '.' + basename(path) + '.' + process.pid + '.' + randomUUID() + '.tmp'); + let handle = null; + try { + handle = await open(temp, 'wx', 0o600); + await handle.writeFile(serialized, 'utf8'); + await handle.sync(); + await handle.close(); + handle = null; + if (onBeforeRename) await onBeforeRename(); + await rename(temp, path); + const directoryHandle = await open(dir, 'r'); + try { await directoryHandle.sync(); } + finally { await directoryHandle.close(); } + } catch (error) { + if (handle) await handle.close().catch(() => {}); + await rm(temp, { force: true }).catch(() => {}); + throw error; + } + } + }); +} diff --git a/scripts/serve-preview.js b/scripts/serve-preview.js new file mode 100644 index 00000000..6ff939ac --- /dev/null +++ b/scripts/serve-preview.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// Isolated, static-only SPOOL candidate preview. No command execution, no DB, +// no cookies or secrets, and no exposure of the source repository outside dist. +import { createServer } from 'node:http'; +import { readFile, stat, realpath } from 'node:fs/promises'; +import { resolve, relative, sep, extname } from 'node:path'; + +const CONTENT_TYPES = Object.freeze({ + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2' +}); +const SPA_ROUTES = new Set([ + '/', '/start', '/integrations', '/developers', '/local-runner', + '/examples', '/security', '/services', '/autopilot', '/how-it-works', + '/webmcp', '/benchmarks', '/docs', '/studio', '/studio/new', + '/studio/mission', '/studio/results' +]); +const SECURITY_HEADERS = Object.freeze({ + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + 'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()', + 'cross-origin-opener-policy': 'same-origin', + 'cross-origin-resource-policy': 'same-origin', + 'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; worker-src 'self' blob:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'", + 'cache-control': 'no-store' +}); + +export function createPreviewHandler({ directory = 'dist' } = {}) { + const root = resolve(directory); + return async (request, response) => { + const headers = { ...SECURITY_HEADERS }; + function respond(code, body = '', type = 'text/plain; charset=utf-8') { + response.writeHead(code, { ...headers, 'content-type': type, 'content-length': Buffer.byteLength(body) }); + response.end(request.method === 'HEAD' ? undefined : body); + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.setHeader('allow', 'GET, HEAD'); + respond(405, 'Method not allowed'); + return; + } + if (request.url === '/healthz') { + respond(200, '{"status":"preview","productionQualified":false}', CONTENT_TYPES['.json']); + return; + } + let pathname; + try { + const url = new URL(request.url ?? '', 'http://localhost'); + pathname = decodeURIComponent(url.pathname); + if (url.search || url.hash || pathname.includes('\\') || pathname.includes('\0') || pathname.includes('..')) { + throw new Error('Unsafe preview path'); + } + } catch { + respond(400, 'Invalid path'); + return; + } + const route = pathname.replace(/\/+$/, '') || '/'; + const servedPath = SPA_ROUTES.has(route) ? '/index.html' : pathname; + const suffix = extname(servedPath); + if (servedPath === '/vercel.json' || !Object.hasOwn(CONTENT_TYPES, suffix)) { + respond(404, 'Not found'); + return; + } + const file = resolve(root, '.' + servedPath); + const rel = relative(root, file); + if (rel.startsWith('..' + sep) || rel === '..' || rel.startsWith(sep)) { + respond(404, 'Not found'); + return; + } + try { + // Lexical containment alone is insufficient: an artifact symlink may + // resolve outside dist even when the URL has no traversal segments. + const rootReal = await realpath(root); + const fileReal = await realpath(file); + const contained = relative(rootReal, fileReal); + if (!contained || contained === '..' || contained.startsWith('..' + sep) || contained.startsWith(sep)) { + throw new Error('Resolved static asset escapes dist'); + } + const st = await stat(fileReal); + if (!st.isFile()) throw new Error('Not a regular file'); + const body = await readFile(fileReal); + response.writeHead(200, { ...headers, 'content-type': CONTENT_TYPES[suffix], 'content-length': body.length }); + response.end(request.method === 'HEAD' ? undefined : body); + } catch { + respond(404, 'Not found'); + } + }; +} + +if (process.argv[1] && process.argv[1].endsWith('serve-preview.js')) { + const port = Number(process.env.PORT ?? 10000); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('PORT must be a TCP port'); + const server = createServer(createPreviewHandler({ directory: process.env.SPOOL_DIST_DIR || 'dist' })); + server.listen(port, '0.0.0.0', () => { + console.log(`SPOOL static preview active on port ${port}; NOT_PRODUCTION_QUALIFIED`); + }); +} diff --git a/src/app.js b/src/app.js index b3b1fa6a..3491d45c 100644 --- a/src/app.js +++ b/src/app.js @@ -7,16 +7,17 @@ import { toolNamesForPhase } from './webmcp/registry.js'; import { initializeOptionalWebMcp } from './webmcp/optional.js'; const ROUTES = Object.freeze([ - '/', '/autopilot', '/how-it-works', '/webmcp', '/benchmarks', '/docs', + '/', '/start', '/integrations', '/developers', '/local-runner', '/examples', '/security', '/services', + '/autopilot', '/how-it-works', '/webmcp', '/benchmarks', '/docs', '/studio', '/studio/new', '/studio/mission', '/studio/results' ]); const PUBLIC_NAV = [ ['/', 'Overview'], - ['/autopilot', 'Autopilot'], + ['/start', 'Start here'], ['/how-it-works', 'How it works'], - ['/webmcp', 'WebMCP'], - ['/benchmarks', 'Benchmarks'], + ['/integrations', 'Integrations'], + ['/developers', 'Developers'], ['/docs', 'Docs'] ]; @@ -181,8 +182,8 @@ function overviewPage(state) {
SPOOL profiles the source, infers a safe target schema, builds deterministic transforms, dry-runs them, executes in a Worker, groups bad rows, recovers from checkpoints, and exposes only the agent tools that make sense right now.
Autopilot is not a script that clicks the old UI. It is a kernel-level orchestration path that plans, dry-runs and starts the migration through the same deterministic commands used everywhere else.
Run Autopilot ${arrowIcon()}Add source↓Choose Database-ready↓Run Autopilot↓No action required${copy}
SPOOL only advances when the target and transform plan satisfy deterministic safety rules. Strong parseability evidence can promote a dirty string field to a type while rows that fail that contract are rejected into quality groups.
Whitespace trim, normalized identifiers, obvious booleans, strongly parseable numbers and ISO dates.
Name collisions, multiple equally plausible interpretations, or a plan that cannot satisfy the typed target contract.
target field: monthly_fee\ninferred type: number\nconfidence: 0.9900\nevidence: 99 / 100 sampled values parse\ndecision: automatic
The application stopped before entering the migration workflow instead of leaving you on a permanent loading screen.
${esc(message)}The application stopped before entering the migration workflow instead of leaving you on a permanent loading screen.
${esc(message)}The Browser Studio prepares and validates data in-browser. The Local Runner is the production mutation path: bounded-memory streaming of approved UTF-8 filesystem CSV migrations into an existing ordinary SQLite table on the customer-controlled machine, with a 256 MiB default source ceiling.
+The Browser Studio prepares and validates data in-browser. The released Local Runner is the customer-local mutation path: bounded-memory streaming of approved UTF-8 filesystem CSV migrations into an existing ordinary SQLite table on the customer-controlled machine, with a 256 MiB default source ceiling.
Node.js 22+ is required. Browser Studio stays capped at 50 MiB; for production SQLite mutation or larger CSV files use this customer-local runner. The Local Runner uses bounded-memory streaming with a 256 MiB default source ceiling. It keeps a durable content-bound snapshot on local disk for approval/recovery, re-scans that snapshot on restart, and cleans it after VERIFIED completion. Budget disk space for the snapshot plus SQLite/WAL growth. The release remains private on the npm registry; install directly from the versioned GitHub source or release artifact.
# Preferred v1.1.1 install — no npm-registry publication required. ++ BEFORE INSTALLINGFor a SQLite database operator
First prepare the CSV and an existing compatible SQLite table. Then install SPOOL locally, create the documented migration request, inspect and dry-run it, authorize the exact plan, execute, verify and save the receipt. The published capability matrix classifies SQLite target support as experimental because sustained-load qualification is still missing; do not interpret a release download as proof of unrestricted enterprise readiness.
Return to the guided setup →+cd spool && npm ci && npm run check && npm run pack:verify ADVANCED · INSTALL + RUN ON YOUR DEVICEInstall the versioned CLI, then inspect, approve, run and verify.
Node.js 22+ is required. Browser Studio stays capped at 50 MiB; for production SQLite mutation or larger CSV files use this customer-local runner. The Local Runner uses bounded-memory streaming with a 256 MiB default source ceiling. It keeps a durable content-bound snapshot on local disk for approval/recovery, re-scans that snapshot on restart, and cleans it after VERIFIED completion. Budget disk space for the snapshot plus SQLite/WAL growth. The release remains private on the npm registry; install directly from the versioned GitHub source or release artifact.
Show command-line installation and execution steps
# Preferred v1.1.1 install — no npm-registry publication required. npm install -g github:dharan1007/spool#v1.1.1 spool --help @@ -102,7 +101,7 @@ spool receipt --migration-id mig_customers_001 --source-root ./data --target-roo # Source-verification fallback: git clone --branch v1.1.1 https://github.com/dharan1007/spool.git -cd spool && npm ci && npm run check && npm run pack:verify
Choose the outcome first. SPOOL will show the correct interface, what runs on your device, and the evidence you will receive. You do not need to understand API responses to choose a path.
You have a CSV, and want a validated CSV or JSON output. Add the file in your browser, choose a cleanup outcome, run Autopilot, review rejected rows and export. Nothing is sent to a SPOOL data server.
Input: CSV up to 50 MiB · Result: browser-local output, not a live database write.
Open the CSV Studio →You have a CSV and an existing SQLite database. Install the released local runner on the computer holding the data. An operator reviews source and destination, runs a dry-run, approves the exact operation, executes it and examines the receipt.
Default CSV ceiling: 256 MiB · Insert-only, ordinary SQLite targets · Command line and setup required.
See the local setup →Your application can invoke SPOOL through its local CLI or an authenticated loopback bridge. A developer installs and runs the command service on the customer-controlled machine. This website does not host a remote migration API.
Execution stays local · Approvals are not bypassed · Requires development work.
Explore integration paths →Those are not part of the currently published v1.1.1 PostgreSQL production scope. Do not put production credentials into Browser Studio or assume an experimental connector is qualified.
Read capability and limit explanations →An integration is software running on your device, not a button that uploads your database to this site. Pick the supported execution surface before writing any code.
Open Studio, choose your CSV and an outcome, inspect validation, and download the result. No API key, server or integration snippet is necessary.
Open Studio →Install v1.1.1 on the machine holding your existing SQLite table and CSV. A developer can invoke the CLI within local automation, preserving the inspect → plan → dry-run → approve → run → verify → receipt sequence.
Local runner instructions →The source includes an authenticated loopback command service. It is not a hosted API and is not started merely by opening this website. Configure it with a local command service, a strong bearer token and an approved Origin policy.
Bridge contract and setup →Machine-readable command responses belong in developer tools; an HTTP endpoint does not provide a human UI. For humans, use Studio or these guides. For applications, use the local CLI or the loopback bridge after provisioning the customer-controlled runtime. This hosted website has no public database-write API, no cloud database connection form and no universal integration token.
Start with the released CLI for production-scoped filesystem CSV to SQLite operations. The loopback HTTP bridge is an integration component, not a remotely hosted SPOOL API. Code examples and machine formats are deliberately kept in the technical section below.
Install the exact released version on the same customer-controlled system as the source and target. Use a separately configured local bridge only when a client requires HTTP.
Inspect source and live target, create a plan, then run a dry-run. Stop on source or contract drift.
Issue the supported bound approval for the exact operation. An API invocation is not itself permission to mutate.
Run only the approved operation, reconcile ambiguous results, verify the supported evidence and retrieve the receipt.
The current source exports a loopback-only daemon factory. A developer must initialize the local command service, supply a strong bearer token, configure allowed Origins and start the server. It accepts authenticated POST requests to /v1/command on its chosen localhost port; a direct browser navigation or GET is not a valid command call. The daemon returns JSON for programmatic consumers, not a rendered migration dashboard.
Do not point production tools at this site's /api path or treat a JSON document as an installation wizard. The published CLI is the release-scoped local execution path.
Method: POST. Host: customer-controlled loopback IP. Path: /v1/command. Authorization: Bearer token, at least 32 bytes. Body: bounded JSON command envelope. Response: structured success or error JSON.
Supported source commands include inspect, plan, dry-run, approve, run, status, verify and receipt; check the exact released adapter before relying on additional commands. The website itself cannot connect to this bridge because its browser Content Security Policy disallows outbound requests.
Review the actual daemon contract → See installation and approval instructions →
Use Browser Studio when you need local CSV preparation and validation. Use the Local Runner when you need a production-verified, bounded-memory write into an existing SQLite table with a durable snapshot, approval, crash reconciliation and a receipt.
Profile, infer, transform, validate, checkpoint, inspect violations and export without a dataset backend. Browser input limit: 50 MiB.
Start in Studio →Bounded-memory stream + 256 MiB default source ceiling + durable snapshot + live target contract + target_write approval + fencing + atomic ledger + verification + receipt.
Open Local Runner →Use Browser Studio when you need local CSV preparation and validation. Use the Local Runner when you need a released, bounded-memory write into an existing SQLite table with a durable snapshot, approval, crash reconciliation and a receipt.
Profile, infer, transform, validate, checkpoint, inspect violations and export without a dataset backend. Browser input limit: 50 MiB.
Start in Studio →Bounded-memory stream + 256 MiB default source ceiling + durable snapshot + live target contract + target_write approval + fencing + atomic ledger + verification + receipt.
Open Local Runner →Clean a CSV in the browser, prepare a local SQLite migration, or connect an existing app. The supported boundary is different for each path.
# Preferred v1.1.1 install'));
+ assert.doesNotMatch(product, /GATE B · PRODUCTION-VERIFIED/);
+ assert.ok(product.includes('sustained-load qualification is still missing'));
+ assert.ok(product.includes('not a remotely hosted SPOOL API'));
+});
+
+test('developer links resolve to source documentation and mobile nav remains accessible', () => {
+ assert.ok(product.includes('href="${REPO}/blob/main/src/daemon/spoold.js"'));
+ assert.ok(!product.includes('\\' + '${REPO}'));
+ assert.match(styles, /\.start-paths/);
+ assert.match(styles, /\.developer-details/);
+ assert.match(styles, /\.guided-home-band/);
+ assert.match(styles, /\.product-topbar \.primary-nav\{display:flex/);
+});
diff --git a/tests/postgres-cdc-capture-ordering.test.js b/tests/postgres-cdc-capture-ordering.test.js
new file mode 100644
index 00000000..2deec3fc
--- /dev/null
+++ b/tests/postgres-cdc-capture-ordering.test.js
@@ -0,0 +1,237 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { createCdcSerialPump } from '../src/connectors/postgres/cdc-serial-pump.js';
+
+const code = readFileSync(new URL('../src/connectors/postgres/cdc.js', import.meta.url), 'utf8');
+const begin = code.indexOf('export async function streamPostgresChanges(');
+const end = code.indexOf('export function createCdcCheckpointStore(', begin);
+assert.ok(begin >= 0 && end > begin, 'real CDC capture implementation is present');
+const captureSource = code.slice(begin, end).replace(/^export /gm, '');
+
+function makeCapture(events, { failAcknowledging = null, refuseAcknowledging = null, honorFlowControl = false, heartbeatLsn = null, heartbeatAfterTag = null, commitEndLsn = null } = {}) {
+ let service;
+ class FakeReplicationService {
+ constructor(_connection, settings) {
+ this.settings = settings;
+ this.listeners = new Map(); this.acknowledged = [];
+ this.stopped = false; this.destroyed = false; this.dispatched = []; service = this;
+ }
+ on(name, cb) { this.listeners.set(name, cb); }
+ async subscribe() {
+ for (const [lsn, kind] of events) {
+ this.dispatched.push(kind);
+ const inflight = this.listeners.get('data')(lsn, { tag: kind, commitEndLsn: kind === 'commit' ? commitEndLsn : null });
+ if (honorFlowControl) await inflight;
+ if (heartbeatLsn && heartbeatAfterTag === kind) this.listeners.get('heartbeat')(heartbeatLsn);
+ }
+ if (heartbeatLsn && !heartbeatAfterTag) this.listeners.get('heartbeat')(heartbeatLsn);
+ }
+ async acknowledge(lsn) {
+ if (lsn === failAcknowledging) throw Object.assign(new Error('injected ACK failure'), { code: 'ACK_FAILED' });
+ if (lsn === refuseAcknowledging) return false;
+ this.acknowledged.push(lsn);
+ return true;
+ }
+ async stop() { this.stopped = true; }
+ async destroy() { this.destroyed = true; }
+ }
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
+ const capture = new Function(
+ 'requireDatabaseRef', 'requireSlotName', 'PUBLICATION_NAME', 'LSN',
+ 'parsePostgresEndpoint', 'LogicalReplicationService', 'PgoutputPlugin',
+ 'normalizeMessage', 'comparePostgresLsn', 'fail',
+ 'classifyCdcStreamError', 'createCdcSerialPump',
+ captureSource + '\nreturn streamPostgresChanges;'
+ )(
+ ref => ref, slot => slot, /^[a-z_]+$/, /^0\/[0-9A-Fa-f]+$/,
+ () => ({ host: 'localhost', port: 5432, sslMode: 'disable' }),
+ FakeReplicationService, class {},
+ (lsn, message) => ({ tag: message.tag, lsn, commitEndLsn: message.commitEndLsn ?? null }),
+ (left, right) => Number.parseInt(left.split('/')[1], 16) - Number.parseInt(right.split('/')[1], 16),
+ fail, error => { throw error; }, createCdcSerialPump
+ );
+ const run = extra => capture({
+ ref: { endpoint: 'postgresql://127.0.0.1:5432?sslmode=disable', database: 'isolated', identity: { user: 'test' }, secretRef: { provider: 'env', key: 'ISOLATED_TEST' } },
+ credentialBroker: { withSecret: async (_, fn) => fn('fake') },
+ slotName: 'isolated_slot', publicationNames: ['isolated_pub'],
+ startLsn: '0/0', untilLsn: '0/3', ...extra
+ });
+ return { run, getService: () => service };
+}
+
+const events = [['0/1', 'begin'], ['0/2', 'insert'], ['0/3', 'commit']];
+
+test('slow durability callback cannot cause a later message to acknowledge first', async () => {
+ let resume;
+ const gate = new Promise(resolve => { resume = resolve; });
+ const harness = makeCapture(events);
+ const observed = [];
+ const running = harness.run({
+ onChange: async change => {
+ if (change.tag === 'begin') await gate;
+ observed.push(change.tag);
+ }
+ });
+ for (let i = 0; i < 5; i++) await Promise.resolve();
+ assert.deepEqual(harness.getService().acknowledged, []);
+ resume();
+ const result = await running;
+ assert.deepEqual(observed, ['begin', 'insert', 'commit']);
+ assert.deepEqual(harness.getService().acknowledged, ['0/3']);
+ assert.equal(result.acknowledgedLsn, '0/3');
+ assert.equal(harness.getService().destroyed, true);
+});
+
+test('replication flow control awaits the complete async callback before reading next WAL message', async () => {
+ let resume;
+ const gate = new Promise(resolve => { resume = resolve; });
+ const harness = makeCapture(events, { honorFlowControl: true });
+ const running = harness.run({
+ onChange: async change => {
+ if (change.tag === 'begin') await gate;
+ }
+ });
+ for (let i = 0; i < 5; i++) await Promise.resolve();
+ assert.deepEqual(harness.getService().dispatched, ['begin']);
+ assert.deepEqual(harness.getService().acknowledged, []);
+ resume();
+ const result = await running;
+ assert.deepEqual(harness.getService().dispatched, ['begin', 'insert', 'commit']);
+ assert.deepEqual(harness.getService().acknowledged, ['0/3']);
+ assert.equal(result.acknowledgedLsn, '0/3');
+});
+
+test('durability failure refuses subsequent messages and never ACKs their WAL positions', async () => {
+ const harness = makeCapture(events);
+ await assert.rejects(harness.run({
+ onChange: async change => {
+ if (change.tag === 'insert') throw Object.assign(new Error('injected apply failure'), { code: 'INJECTED_FAILURE' });
+ }
+ }), { code: 'INJECTED_FAILURE' });
+ assert.deepEqual(harness.getService().acknowledged, []);
+ assert.equal(harness.getService().destroyed, true);
+});
+
+test('maximum message limit refuses incomplete source transaction before ACKing the boundary event', async () => {
+ const harness = makeCapture(events);
+ const delivered = [];
+ await assert.rejects(harness.run({
+ maxMessages: 2, onChange: async change => { delivered.push(change.tag); }
+ }), { code: 'CDC_CAPTURE_BOUNDARY_INCOMPLETE' });
+ assert.deepEqual(delivered, ['begin']);
+ assert.deepEqual(harness.getService().acknowledged, []);
+});
+
+test('LSN waterline is applied only on COMMIT, not to uncommitted row messages', async () => {
+ const harness = makeCapture(events);
+ const result = await harness.run({ untilLsn: '0/2' });
+ assert.deepEqual(harness.getService().acknowledged, ['0/3']);
+ assert.equal(result.acknowledgedLsn, '0/3');
+});
+
+test('transport ACK rejection cannot advance the reported commit LSN', async () => {
+ const harness = makeCapture(events, { failAcknowledging: '0/3' });
+ await assert.rejects(harness.run(), { code: 'ACK_FAILED' });
+ assert.deepEqual(harness.getService().acknowledged, []);
+});
+
+test('callback queue bound refuses overflow without processing subsequent events', async () => {
+ let release;
+ const gate = new Promise(resolve => { release = resolve; });
+ const applied = [];
+ const errors = [];
+ const pump = createCdcSerialPump({
+ maxPending: 2,
+ handle: async (_, message) => { await gate; applied.push(message); },
+ onFailure: error => errors.push(error)
+ });
+ assert.equal(pump.enqueue('0/1', 1), true);
+ assert.equal(pump.enqueue('0/2', 2), true);
+ assert.equal(pump.enqueue('0/3', 3), false);
+ assert.equal(errors.length, 1);
+ assert.equal(errors[0].code, 'CDC_EVENT_BACKPRESSURE_EXCEEDED');
+ release();
+ await pump.drain();
+ assert.deepEqual(applied, []);
+ assert.deepEqual(pump.stats(), { accepting: false, pending: 0 });
+});
+
+
+test('capture reports truncation instead of silently certifying an incomplete CDC window', async () => {
+ const harness = makeCapture(events);
+ const captured = await harness.run({ maxCollectedChanges: 2 });
+ assert.equal(captured.messageCount, 3);
+ assert.equal(captured.collectedChangeCount, 2);
+ assert.equal(captured.truncatedChanges, true);
+ assert.deepEqual(captured.changes.map(change => change.tag), ['insert', 'commit']);
+});
+
+test('capture refuses invalid collection bounds rather than dropping evidence', async () => {
+ for (const maxCollectedChanges of [0, -1, 1.5, Infinity, 1_000_001]) {
+ const harness = makeCapture(events);
+ await assert.rejects(harness.run({ maxCollectedChanges }), { code: 'INVALID_POSTGRES_CDC_CONFIG' });
+ }
+});
+
+test('commit END-LSN satisfies flush-waterline without waiting for another source transaction', async () => {
+ const harness = makeCapture(events, { commitEndLsn: '0/4' });
+ const result = await harness.run({ untilLsn: '0/4' });
+ assert.equal(result.lastLsn, '0/4');
+ assert.equal(result.acknowledgedLsn, '0/4');
+ assert.equal(harness.getService().stopped, true);
+});
+
+test('idle WAL heartbeat completes a fully drained capture after final commit', async () => {
+ const harness = makeCapture(events, { heartbeatLsn: '0/5' });
+ const result = await harness.run({ untilLsn: '0/4' });
+ assert.equal(result.messageCount, 3);
+ assert.equal(result.changes.at(-1).tag, 'commit');
+ assert.equal(harness.getService().acknowledged.join(','), '0/3');
+ assert.equal(result.lastLsn, '0/3');
+ assert.equal(harness.getService().stopped, true);
+});
+
+test('heartbeat never terminates an open source transaction', async () => {
+ const harness = makeCapture(events, {
+ heartbeatLsn: '0/5', heartbeatAfterTag: 'insert', honorFlowControl: true
+ });
+ const result = await harness.run({ untilLsn: '0/4' });
+ assert.deepEqual(result.changes.map(change => change.tag), ['begin','insert','commit']);
+ assert.equal(harness.getService().acknowledged.length, 1);
+ assert.equal(result.messageCount, 3);
+});
+
+
+test('manual pgoutput capture never enables the driver periodic volatile-WAL ACK timer', async () => {
+ const harness = makeCapture(events);
+ await harness.run();
+ assert.deepEqual(harness.getService().settings.acknowledge, {auto:false, timeoutSeconds:0});
+ const applierCode = readFileSync(new URL('../src/connectors/postgres/cdc-apply.js', import.meta.url),'utf8');
+ assert.ok(applierCode.includes('acknowledge: { auto: false, timeoutSeconds: 0 }'));
+});
+
+test('a complete source transaction is the FIRST WAL position acknowledged', async () => {
+ const harness = makeCapture(events, {commitEndLsn:'0/4'});
+ const result = await harness.run({untilLsn:'0/4'});
+ assert.deepEqual(harness.getService().acknowledged,['0/4']);
+ assert.equal(result.acknowledgedLsn,'0/4');
+});
+
+test('driver refusal to acknowledge a COMMIT fails closed and cannot report success', async () => {
+ const harness = makeCapture(events,{refuseAcknowledging:'0/3'});
+ await assert.rejects(harness.run(), {code:'CDC_ACK_FAILED'});
+ assert.deepEqual(harness.getService().acknowledged,[]);
+});
+
+test('orphan COMMIT is never acknowledged',async () => {
+ const harness=makeCapture([['0/1','commit']]);
+ await assert.rejects(harness.run(),{code:'CDC_PROTOCOL_VIOLATION'});
+ assert.deepEqual(harness.getService().acknowledged,[]);
+});
+
+test('row mutation without source BEGIN is never acknowledged',async () => {
+ const harness=makeCapture([['0/1','insert']]);
+ await assert.rejects(harness.run(),{code:'CDC_PROTOCOL_VIOLATION'});
+ assert.deepEqual(harness.getService().acknowledged,[]);
+});
diff --git a/tests/preview-server.test.js b/tests/preview-server.test.js
new file mode 100644
index 00000000..a8ec2e43
--- /dev/null
+++ b/tests/preview-server.test.js
@@ -0,0 +1,70 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+import { once } from 'node:events';
+import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { createPreviewHandler } from '../scripts/serve-preview.js';
+
+async function withServer(task) {
+ const root = await mkdtemp(join(tmpdir(), 'spool-preview-test-'));
+ const server = createServer(createPreviewHandler({directory:root}));
+ try {
+ await mkdir(join(root,'src'));
+ await writeFile(join(root,'index.html'),'isolated-preview');
+ await writeFile(join(root,'src/app.js'), 'export default 1;');
+ await writeFile(join(root,'vercel.json'), '{"unsafe":true}');
+ server.listen(0,'127.0.0.1'); await once(server,'listening');
+ return await task('http://127.0.0.1:'+server.address().port, root);
+ } finally {
+ if (server.listening) await new Promise(resolve=>server.close(resolve));
+ await rm(root,{recursive:true,force:true});
+ }
+}
+test('health is truthful and discloses preview rather than production',()=>withServer(async u=>{
+ const r=await fetch(u+'/healthz');
+ assert.equal(r.status,200);
+ assert.deepEqual(await r.json(),{status:'preview',productionQualified:false});
+}));
+test('all human and studio deep links return shell, not a 404',()=>withServer(async u=>{
+ for (const path of ['/','/start','/local-runner','/integrations','/developers','/studio/new','/studio/results']) {
+ const r=await fetch(u+path);
+ assert.equal(r.status,200,path);
+ assert.match(await r.text(),/isolated-preview/);
+ assert.match(r.headers.get('content-security-policy'),/connect-src 'none'/);
+ }
+}));
+test('static module correct MIME, no API, no repo metadata or host config exposed',()=>withServer(async u=>{
+ assert.match((await fetch(u+'/src/app.js')).headers.get('content-type'),/text\/javascript/);
+ for (const path of ['/api','/package.json','/vercel.json','/secret.env','/src/nothing.js']) {
+ assert.equal((await fetch(u+path)).status,404,path);
+ }
+}));
+test('reject invalid methods and encoded path traversal',()=>withServer(async u=>{
+ assert.equal((await fetch(u+'/studio/new',{method:'POST'})).status,405);
+ assert.equal((await fetch(u+'/%2e%2e%5csecret')).status,400);
+ assert.equal((await fetch(u+'/%00')).status,400);
+}));
+test('HEAD returns correct length but no body',()=>withServer(async u=>{
+ const r=await fetch(u+'/studio',{method:'HEAD'});
+ assert.equal(r.status,200);
+ assert.equal(r.headers.get('content-length'),String(Buffer.byteLength('isolated-preview')));
+ assert.equal(await r.text(),'');
+}));
+
+test('symlinks inside dist cannot leak files or directories from outside the static artifact',()=>withServer(async (u,root)=>{
+ const outside = await mkdtemp(join(tmpdir(),'spool-preview-outside-'));
+ try {
+ await writeFile(join(outside,'private.html'),'OUTSIDE_DIST_SENTINEL');
+ await symlink(join(outside,'private.html'),join(root,'src','escape.html'));
+ await symlink(outside,join(root,'src','escape-folder'));
+ for (const path of ['/src/escape.html','/src/escape-folder/private.html']) {
+ const response = await fetch(u+path);
+ assert.equal(response.status,404,path);
+ assert.doesNotMatch(await response.text(),/OUTSIDE_DIST_SENTINEL/);
+ }
+ } finally {
+ await rm(outside,{recursive:true,force:true});
+ }
+}));
diff --git a/tests/product-route-bridge.test.js b/tests/product-route-bridge.test.js
new file mode 100644
index 00000000..41db6584
--- /dev/null
+++ b/tests/product-route-bridge.test.js
@@ -0,0 +1,121 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const appSource = readFileSync(new URL('../src/app.js', import.meta.url), 'utf8');
+const productSource = readFileSync(new URL('../src/product-surface.js', import.meta.url), 'utf8');
+const PRODUCT_PATHS = ['/local-runner', '/examples', '/security', '/services', '/start', '/integrations', '/developers'];
+
+function productBridge() {
+ const window = { location: { pathname: '/start' }, addEventListener() {} };
+ const document = { getElementById() { return null; } };
+ return new Function('window', 'document', 'MutationObserver', 'queueMicrotask',
+ productSource + '\nreturn window.__spoolProductSurface;'
+ )(window, document, class {}, () => {});
+}
+
+function appFunction(name, nextName, extra) {
+ const begin = appSource.indexOf('function ' + name + '(');
+ let end = appSource.indexOf('\nfunction ' + nextName + '(', begin);
+ if (end < 0) end = appSource.indexOf('\nasync function ' + nextName + '(', begin);
+ assert.ok(begin >= 0 && end > begin, name + ' must have a bounded definition');
+ return new Function(...extra.names, appSource.slice(begin, end) + '\nreturn ' + name + ';')(...extra.values);
+}
+
+test('every supported product page renders actual human-facing HTML without undefined placeholders', () => {
+ const bridge = productBridge();
+ for (const path of PRODUCT_PATHS) {
+ const html = bridge.render(path);
+ assert.ok(html.includes('data-product-route="' + path + '"'), path);
+ assert.ok(html.includes(' {
+ const bridge = productBridge();
+ for (const path of PRODUCT_PATHS) {
+ const root = { innerHTML: '' };
+ const document = { title: '' };
+ const render = appFunction('render', 'pageTitle', {
+ names: ['appRoot', 'latestState', 'currentPath', 'window', 'document'],
+ values: [root, {}, () => path, { __spoolProductSurface: bridge }, document]
+ });
+ render();
+ assert.equal(root.innerHTML, bridge.render(path), path);
+ assert.equal(document.title, bridge.title(path) + ' — SPOOL', path);
+ }
+});
+
+test('valid product URLs show loading state, never 404, if sibling bundle has not evaluated', () => {
+ const root = { innerHTML: '' };
+ const render = appFunction('render', 'pageTitle', {
+ names: ['appRoot', 'latestState', 'currentPath', 'window', 'document'],
+ values: [root, {}, () => '/start', {}, { title: '' }]
+ });
+ render();
+ assert.match(root.innerHTML, /Loading SPOOL/);
+ assert.doesNotMatch(root.innerHTML, /404/);
+});
+
+test('navigation accepts product URLs and rejects unknown routes', () => {
+ const routeDefinition = appSource.match(/const ROUTES = Object\.freeze\((\[[\s\S]*?\])\);/);
+ assert.ok(routeDefinition);
+ const routes = new Function('return ' + routeDefinition[1])();
+ for (const path of PRODUCT_PATHS) assert.ok(routes.includes(path), path);
+ const moved = [];
+ const window = {
+ location: { pathname: '/' },
+ history: { pushState(_, __, path) { moved.push(path); } },
+ scrollTo() {}
+ };
+ const navigate = appFunction('navigate', 'scheduleRender', {
+ names: ['ROUTES', 'window', 'render'],
+ values: [routes, window, () => {}]
+ });
+ navigate('/local-runner');
+ navigate('/start');
+ navigate('/not-a-route');
+ assert.deepEqual(moved, ['/local-runner', '/start']);
+});
+
+test('product-to-Studio clicks stay in SPA; modified, external, or download clicks stay native', () => {
+ let handler;
+ const routes = ['/', '/studio/new', '/start', '/local-runner'];
+ const moved = [];
+ const root = { addEventListener(type, listener) { if (type === 'click') handler = listener; } };
+ const window = { addEventListener() {} };
+ const bind = appFunction('bindGlobalEvents', 'initWebMcp', {
+ names: ['appRoot', 'window', 'ROUTES', 'navigate', 'render'],
+ values: [root, window, routes, route => moved.push(route), () => {}]
+ });
+ bind();
+ assert.equal(typeof handler, 'function');
+ function click(href, options = {}) {
+ let prevented = false;
+ const anchor = {
+ dataset: {},
+ getAttribute(name) { return name === 'href' ? href : null; },
+ hasAttribute(name) { return name === 'download' ? Boolean(options.download) : false; }
+ };
+ handler({
+ target: { closest(selector) { return selector === '[data-route], a[href^="/"]' ? anchor : null; } },
+ defaultPrevented: false,
+ button: 0,
+ ...options,
+ preventDefault() { prevented = true; }
+ });
+ return prevented;
+ }
+ assert.equal(click('/studio/new'), true);
+ assert.equal(click('/start'), true);
+ assert.equal(click('/start', { ctrlKey: true }), false);
+ assert.equal(click('/start', { metaKey: true }), false);
+ assert.equal(click('/start', { button: 1 }), false);
+ assert.equal(click('/start', { download: true }), false);
+ assert.equal(click('https://example.com/'), false);
+ assert.deepEqual(moved, ['/studio/new', '/start']);
+});
diff --git a/tests/qualify-cdc-capture-proof.test.js b/tests/qualify-cdc-capture-proof.test.js
new file mode 100644
index 00000000..a7bad5ed
--- /dev/null
+++ b/tests/qualify-cdc-capture-proof.test.js
@@ -0,0 +1,80 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { auditCdcCapture, compareCaptureLsn } from '../scripts/qualify/postgres/cdc-capture-proof.js';
+
+const event = (tag, lsn, extra = {}) => ({ tag, lsn, ...extra });
+const tx = (start, row, commit, end) => [
+ event('begin', start),
+ event('insert', row),
+ event('commit', commit, { commitEndLsn: end })
+];
+
+test('concurrent long transaction with old row LSN is not a false replay', () => {
+ const proof = auditCdcCapture(tx('0/10','0/12','0/31','0/33'), { priorCheckpointLsn: '0/30' });
+ assert.deepEqual(proof, { checkpointLsn: '0/33', commits: 1, rowEvents: 1 });
+});
+
+test('cursor records end of complete commit, not earlier row or record start LSN', () => {
+ const proof = auditCdcCapture(tx('0/60','0/61','0/64','0/66'));
+ assert.equal(proof.checkpointLsn, '0/66');
+});
+
+test('cross-window commit regression is rejected against prior durable end cursor', () => {
+ assert.throws(() => auditCdcCapture(tx('0/10','0/11','0/1F','0/21'), {
+ priorCheckpointLsn: '0/20'
+ }), { code: 'CDC_RESTART_REPLAY' });
+});
+
+test('commit end cannot precede its own WAL record', () => {
+ assert.throws(() => auditCdcCapture(tx('0/10','0/11','0/21','0/20')), {
+ code: 'CDC_CAPTURE_INVALID'
+ });
+});
+
+test('within-transaction row WAL record order remains monotonic', () => {
+ assert.throws(() => auditCdcCapture([
+ event('begin','0/10'), event('insert','0/13'), event('update','0/12'),
+ event('commit','0/20')
+ ]), { code: 'CDC_STREAM_ORDER_VIOLATION' });
+});
+
+test('row mutation outside begin/commit is never accepted', () => {
+ assert.throws(() => auditCdcCapture([event('delete','0/10')]), {
+ code: 'CDC_CAPTURE_INCOMPLETE'
+ });
+});
+
+test('truncated open transaction is not a successful capture', () => {
+ assert.throws(() => auditCdcCapture([
+ event('begin','0/10'), event('insert','0/11')
+ ]), { code: 'CDC_CAPTURE_INCOMPLETE' });
+});
+
+test('nested begin, orphan commit, and no-commit capture fail closed', () => {
+ assert.throws(() => auditCdcCapture([
+ event('begin','0/10'), event('begin','0/11')
+ ]), { code: 'CDC_CAPTURE_INCOMPLETE' });
+ assert.throws(() => auditCdcCapture([event('commit','0/21')]), {
+ code: 'CDC_CAPTURE_INCOMPLETE'
+ });
+ assert.throws(() => auditCdcCapture([], { priorCheckpointLsn: '0/20' }), {
+ code: 'CDC_CAPTURE_INCOMPLETE'
+ });
+});
+
+test('transient relation metadata does not break complete transaction auditing', () => {
+ const proof = auditCdcCapture([
+ event('relation','0/10'), event('begin','0/11'),
+ event('relation','0/12'), event('insert','0/13'),
+ event('commit','0/14')
+ ]);
+ assert.equal(proof.commits,1);
+ assert.equal(proof.rowEvents,1);
+});
+
+test('64-bit LSN comparisons do not truncate to JavaScript floating precision', () => {
+ assert.equal(compareCaptureLsn('FFFFFFFF/FFFFFFFF','FFFFFFFF/FFFFFFFE'),1);
+ assert.equal(compareCaptureLsn('00000001/0','0/FFFFFFFF'),1);
+ assert.equal(compareCaptureLsn('ABCDEF00/00000002','ABCDEF00/00000002'),0);
+ assert.throws(()=>compareCaptureLsn('0/not-a-lsn','0/1'), { code:'INVALID_POSTGRES_LSN' });
+});
diff --git a/tests/qualify-cdc-soak.test.js b/tests/qualify-cdc-soak.test.js
new file mode 100644
index 00000000..8502ee72
--- /dev/null
+++ b/tests/qualify-cdc-soak.test.js
@@ -0,0 +1,122 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const code = readFileSync(new URL('../scripts/qualify/postgres/cdc-soak.js', import.meta.url), 'utf8');
+const start = code.indexOf(' async function writePlan(chunk) {');
+const end = code.indexOf(' // Audit every complete pgoutput transaction', start);
+assert.ok(start >= 0 && end > start, 'production CDC qualification writer is present');
+const functionBody = code.slice(start, end);
+// Test the actual production operation counter. A test-local replacement for
+// opEvents previously hid a live-writer accounting defect.
+const mixStart = code.indexOf('function opMix(writer, opIndex) {');
+const mixEnd = code.indexOf('export async function runCdcSoak(', mixStart);
+assert.ok(mixStart >= 0 && mixEnd > mixStart, 'production CDC operation mix is present');
+const { opMix, opEvents } = new Function(code.slice(mixStart, mixEnd) + '\nreturn { opMix, opEvents };')();
+
+test('production counter includes both WAL row changes for update/delete/multi', () => {
+ assert.equal(opEvents('insert'), 1);
+ for (const kind of ['update','delete','multi']) assert.equal(opEvents(kind), 2);
+ assert.throws(() => opEvents('not-a-real-operation'), /Unsupported deterministic/);
+});
+
+test('mixed workload event budget exactly matches emitted source row operations', () => {
+ let inserted = 0, updated = 0, deleted = 0;
+ const kinds = new Set();
+ let expected = 0;
+ for (let writer = 0; writer < 4; writer += 1) {
+ for (let op = 0; op < 100; op += 1) {
+ const kind = opMix(writer, op);
+ kinds.add(kind);
+ inserted += kind === 'multi' ? 2 : 1;
+ if (kind === 'update') updated += 1;
+ if (kind === 'delete') deleted += 1;
+ expected += opEvents(kind);
+ }
+ }
+ assert.deepEqual([...kinds].sort(), ['delete','insert','multi','update']);
+ assert.equal(expected, inserted + updated + deleted);
+});
+
+async function exercise(kind, { workers = 1, fault = null, chunk = 0 } = {}) {
+ const sessions = [];
+ class Client {
+ constructor() { this.calls = []; this.params = []; this.closed = false; sessions.push(this); }
+ async connect() {}
+ async query(sql, values = []) {
+ this.calls.push(sql);
+ this.params.push(values);
+ if (fault && ((fault === 'SECOND_INSERT' && sql.startsWith('INSERT') && this.calls.filter(call => call.startsWith('INSERT')).length === 2)
+ || (fault !== 'SECOND_INSERT' && sql.includes(fault)))) throw new Error('injected PostgreSQL failure');
+ return { rowCount: 1 };
+ }
+ async end() { this.closed = true; }
+ }
+ const fn = new Function(
+ 'Client', 'pgConfig', 'writers', 'opsPerChunk', 'opMix', 'WRITER_BASE',
+ 'WRITER_STRIDE', 'MULTI_OFFSET', 'table', 'opEvents', 'sleep', 'writerPaceMs',
+ functionBody + '\nreturn writePlan;'
+ )(
+ Client, { host: '127.0.0.1', port: 5432, database: 'isolated', user: 'test', password: 'fixture' },
+ workers, 100, () => kind, 100_000_000, 20_000_000, 10_000_000,
+ 'q_cdc_test', opEvents, () => Promise.resolve(), 0
+ );
+ let result, error;
+ try { result = await fn(chunk); } catch (caught) { error = caught; }
+ return { result, error, sessions };
+}
+
+test('seven-day CDC workload is generated in bounded windows, not preallocated arrays', () => {
+ assert.match(code, /const CHUNK_WINDOW_MS = 30_000/);
+ assert.match(code, /const opsPerChunk = 100/);
+ assert.doesNotMatch(code, /const plans =|plans\[chunk\]/);
+ assert.match(code, /const observedSoakMs = Number\(process\.hrtime\.bigint\(\) - soakStart\)/);
+ assert.match(code, /CDC_SOAK_UNDER_EXECUTED/);
+ const chunks = Math.ceil(7 * 86400 * 1000 / 30000);
+ assert.equal(chunks, 20160);
+ const maxOpIndex = chunks * 100 - 1;
+ const lastId = 100_000_000 + 49 * 20_000_000 + maxOpIndex + 1 + 10_000_000;
+ assert.ok(lastId < 2_147_483_647);
+});
+
+for (const kind of ['insert', 'update', 'delete', 'multi']) {
+ test('real source mutations and counted CDC effects: ' + kind, async () => {
+ const { result, error, sessions } = await exercise(kind);
+ assert.equal(error, undefined);
+ assert.equal(result.operations, 100);
+ assert.equal(result.inserted, kind === 'multi' ? 200 : 100);
+ assert.equal(result.attempted, kind === 'insert' ? 100 : 200);
+ assert.equal(sessions.length, 1);
+ assert.equal(sessions[0].closed, true);
+ const log = sessions[0].calls.map(query => query.split(' ')[0]);
+ if (kind === 'insert') assert.deepEqual(log.slice(0, 1), ['INSERT']);
+ else assert.deepEqual(log.slice(0, 4), ['BEGIN', 'INSERT', kind === 'multi' ? 'INSERT' : kind === 'delete' ? 'DELETE' : 'UPDATE', 'COMMIT']);
+ if (kind === 'multi') {
+ assert.equal(sessions[0].params[1][0] - sessions[0].params[2][0], -10_000_000, 'two source rows must have distinct keys');
+ }
+ assert.equal(log.some(sql => sql.includes(';')), false, 'node-postgres parameterized multi-statements forbidden');
+ });
+}
+
+test('concurrent fault rolls back incomplete transaction and awaits every worker shutdown', async () => {
+ const { result, error, sessions } = await exercise('multi', { workers: 2, fault: 'SECOND_INSERT' });
+ assert.equal(result, undefined);
+ assert.match(error?.message ?? '', /injected PostgreSQL failure/);
+ assert.equal(sessions.length, 2);
+ assert.ok(sessions.every(client => client.closed));
+ assert.ok(sessions.every(client => client.calls.includes('ROLLBACK')));
+ assert.ok(sessions.every(client => !client.calls.includes('COMMIT')));
+});
+
+test('identifier-space exhaustion fails instead of aliasing another writer or marker', async () => {
+ const { result, error, sessions } = await exercise('insert', { chunk: 100000 });
+ assert.equal(result, undefined);
+ assert.match(error?.message ?? '', /exhausted/);
+ assert.ok(sessions.every(client => client.closed));
+});
+
+test('a driver exception cannot be silently counted as completed qualification evidence', () => {
+ assert.doesNotMatch(functionBody, /catch\s*\{\s*\}/);
+ assert.match(functionBody, /if \(insertedRow\.rowCount|if \(added\.rowCount/);
+ assert.match(code, /CDC_ACTUAL_EVENT_COUNT_MISMATCH/);
+});
diff --git a/tests/qualify-cdc-writer-transaction.test.js b/tests/qualify-cdc-writer-transaction.test.js
new file mode 100644
index 00000000..0b6d6bc3
--- /dev/null
+++ b/tests/qualify-cdc-writer-transaction.test.js
@@ -0,0 +1,61 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const file = readFileSync(new URL('../scripts/qualify/postgres/cdc-production-lifecycle.js', import.meta.url), 'utf8');
+const start = file.indexOf(' async function writerTask(');
+const end = file.indexOf('\n try {\n const setup =', start);
+assert.ok(start >= 0 && end > start, 'executable qualification writer must exist');
+const writerBody = file.slice(start, end);
+
+async function executeOneWriterOperation({ worker = 0, failSql = null } = {}) {
+ const queries = [];
+ let closed = false;
+ let ticks = 0;
+ const client = {
+ async query(sql) {
+ queries.push(sql);
+ if (failSql && sql.includes(failSql)) throw new Error('injected source transaction fault');
+ return { rowCount: 1 };
+ },
+ async end() { closed = true; }
+ };
+ const fakeDate = { now() { return ticks++ === 0 ? 0 : 2; } };
+ const createWriter = new Function('admin', 'rowCount', 'sourceTable', 'Date', 'setTimeout',
+ writerBody + '\nreturn writerTask;'
+ );
+ const writer = createWriter(async () => client, 100, 'qualification_source', fakeDate,
+ () => Promise.resolve());
+ let result;
+ let error;
+ try { result = await writer(worker, 1); } catch (caught) { error = caught; }
+ return { queries, closed, result, error };
+}
+
+test('each seeded insert is committed as one actual PostgreSQL transaction', async () => {
+ const observed = await executeOneWriterOperation();
+ assert.deepEqual(observed.queries.map(query => query.split(' ')[0]),
+ ['BEGIN', 'INSERT', 'COMMIT']);
+ assert.deepEqual({ ops: observed.result.ops, transactions: observed.result.transactions },
+ { ops: 1, transactions: 1 });
+ assert.equal(observed.closed, true);
+});
+test('CDC delete-plus-reinsert is one atomic source transaction', async () => {
+ const observed = await executeOneWriterOperation({ worker: 7 }); // (7*13) % 100 = 91
+ assert.deepEqual(observed.queries.map(query => query.split(' ')[0]),
+ ['BEGIN', 'DELETE', 'INSERT', 'COMMIT']);
+ assert.equal(observed.result.transactions, 1);
+ assert.equal(observed.closed, true);
+});
+test('failure after delete rolls back both effects, never counts a committed transaction', async () => {
+ const observed = await executeOneWriterOperation({ worker: 7, failSql: 'INSERT' });
+ assert.deepEqual(observed.queries.map(query => query.split(' ')[0]),
+ ['BEGIN', 'DELETE', 'INSERT', 'ROLLBACK']);
+ assert.match(observed.error?.message ?? '', /injected source transaction fault/);
+ assert.equal(observed.closed, true);
+});
+test('qualification reports actual source commits, not loop-count approximations', () => {
+ assert.ok(file.includes('committedSourceTransactions !== totalOps'));
+ assert.ok(file.includes('event: \'writers_quiesced\', totalOps, committedSourceTransactions'));
+ assert.ok(!file.includes('transactions += 1;\n await new Promise'));
+});
diff --git a/tests/qualify-durable-checkpoint.test.js b/tests/qualify-durable-checkpoint.test.js
new file mode 100644
index 00000000..55b4fd7d
--- /dev/null
+++ b/tests/qualify-durable-checkpoint.test.js
@@ -0,0 +1,90 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { createDurableJsonCheckpointStore } from '../scripts/qualify/postgres/durable-checkpoint.js';
+
+async function withTemp(fn) {
+ const dir = await mkdtemp(join(tmpdir(), 'spool-durable-checkpoint-'));
+ try { return await fn(dir); }
+ finally { await rm(dir, { recursive: true, force: true }); }
+}
+
+test('fsync/rename checkpoint is complete and owner-only across reopen', async () => withTemp(async dir => {
+ const path = join(dir, 'checkpoint.json');
+ const store = createDurableJsonCheckpointStore({ path });
+ assert.equal(await store.load(), null);
+ await store.save({ nextOffset: 10, id: 'first' });
+ assert.deepEqual(await createDurableJsonCheckpointStore({path}).load(), {nextOffset:10,id:'first'});
+ await store.save({ nextOffset: 20, id: 'second' });
+ assert.deepEqual(await store.load(), { nextOffset:20,id:'second' });
+ if (process.platform !== 'win32') assert.equal((await stat(path)).mode & 0o777, 0o600);
+}));
+
+test('fault after temp file fsync never truncates the previous published checkpoint', async () => withTemp(async dir => {
+ const path = join(dir, 'checkpoint.json');
+ const store = createDurableJsonCheckpointStore({path});
+ await store.save({nextOffset:10});
+ const faulted = createDurableJsonCheckpointStore({path, onBeforeRename: async () => {
+ throw new Error('injected crash boundary');
+ }});
+ await assert.rejects(faulted.save({nextOffset:20}), /injected crash boundary/);
+ assert.deepEqual(await store.load(), {nextOffset:10});
+ assert.deepEqual(await readdir(dir), ['checkpoint.json'], 'abandoned temp file must be deleted');
+}));
+
+test('corrupt checkpoint fails closed instead of silently replaying migration', async () => withTemp(async dir => {
+ const path = join(dir, 'checkpoint.json');
+ const store = createDurableJsonCheckpointStore({path});
+ await writeFile(path, '{"nextOffset":', 'utf8');
+ await assert.rejects(store.load(), SyntaxError);
+ await writeFile(path, 'null', 'utf8');
+ await assert.rejects(store.load(), /must be a JSON object/);
+ await assert.rejects(store.save(null), TypeError);
+}));
+
+test('actual SIGKILL between temp fsync and rename leaves previous durable checkpoint', {
+ skip: process.platform !== 'linux'
+}, async () => withTemp(async dir => {
+ const path = join(dir, 'checkpoint.json');
+ const store = createDurableJsonCheckpointStore({path});
+ await store.save({nextOffset:100});
+ const moduleUrl = new URL('../scripts/qualify/postgres/durable-checkpoint.js', import.meta.url).href;
+ const script = [
+ 'import {createDurableJsonCheckpointStore} from ' + JSON.stringify(moduleUrl) + ';',
+ 'const store = createDurableJsonCheckpointStore({path:process.argv[1],',
+ 'onBeforeRename:async()=>{process.stdout.write("READY\\n");await new Promise(()=>{})}});',
+ 'await store.save({nextOffset:200});'
+ ].join('\n');
+ const child = spawn(process.execPath, ['--input-type=module','--eval',script,path], {
+ stdio:['ignore','pipe','pipe']
+ });
+ let output = '';
+ let stderr = '';
+ child.stderr.on('data', c => { stderr += c.toString(); });
+ const exit = new Promise(resolve => child.on('close',(code,signal)=>resolve({code,signal})));
+ try {
+ let timeout;
+ try {
+ await Promise.race([
+ new Promise((resolve,reject)=>{
+ child.stdout.on('data', chunk=>{
+ output += chunk.toString();
+ if (output.includes('READY\n')) resolve();
+ });
+ child.on('error', reject);
+ child.on('close', ()=>reject(new Error('child died before fsync: '+stderr)));
+ }),
+ new Promise((_,reject)=>{ timeout = setTimeout(()=>reject(new Error('child fsync boundary timeout: '+stderr)),5000); })
+ ]);
+ } finally { clearTimeout(timeout); }
+ assert.equal(child.kill('SIGKILL'), true);
+ assert.equal((await exit).signal, 'SIGKILL');
+ assert.deepEqual(await store.load(), {nextOffset:100});
+ assert.equal(JSON.parse(await readFile(path,'utf8')).nextOffset,100);
+ } finally {
+ if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
+ }
+}));
diff --git a/tests/qualify-snapshot-fanout.test.js b/tests/qualify-snapshot-fanout.test.js
new file mode 100644
index 00000000..5ffe6dd1
--- /dev/null
+++ b/tests/qualify-snapshot-fanout.test.js
@@ -0,0 +1,96 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const source = readFileSync(new URL('../scripts/qualify/postgres/database-source-load.js', import.meta.url), 'utf8');
+
+async function qualifyMock(writers, { wrongJoined = false, missingLive = false } = {}) {
+ const tables = new Map(), writes = [], readers = [];
+ let exporterOpen = false;
+ class Client {
+ async connect() {}
+ async end() {}
+ async query(sql) {
+ const table = /public\."([^"]+)"/.exec(sql)?.[1];
+ if (sql.startsWith('DROP TABLE')) { tables.delete(table); return { rowCount: 0 }; }
+ if (sql.startsWith('CREATE TABLE')) { tables.set(table, 0); return { rowCount: 0 }; }
+ if (sql.includes('generate_series(1, 1000)')) { tables.set(table, 1000); return { rowCount: 1000 }; }
+ if (sql.includes('VALUES (1000001')) {
+ assert.equal(exporterOpen, true, 'post-export write happened after snapshot owner closed');
+ tables.set(table, tables.get(table) + 1);
+ writes.push(table);
+ return { rowCount: 1 };
+ }
+ if (sql.startsWith('SELECT count(*)::int')) {
+ return { rows: [{ c: missingLive && table === writes[0] ? 1000 : tables.get(table) }] };
+ }
+ throw new Error('Unexpected mock SQL: ' + sql);
+ }
+ }
+ const fakeHash = () => ({ update() { return this; }, digest() { return 'abcdef0123456789'; } });
+ const sourceRuntime = () => ({
+ async withSnapshot(ref, read, opts) {
+ assert.equal(exporterOpen, true, 'joined reader ran after export transaction ended');
+ assert.equal(writes.length, writers * 2, 'read began before all post-export writes');
+ assert.equal(opts.snapshotToken, 'snapshot-1');
+ readers.push(ref.table);
+ const count = wrongJoined && readers.length === 1 ? 1001 : 1000;
+ return read({ async *batches() { yield { rows: Array(count) }; } });
+ }
+ });
+ const executable = source.replace(/^import[\s\S]*?;\s*$/gm, '').replace(/^export /gm, '');
+ const qualify = new Function(
+ 'createHash', 'Client', 'CredentialBroker', 'discoverPostgresCatalog',
+ 'withExportedPostgresSnapshot', 'createPostgresSourceRuntime',
+ executable + '\nreturn runDatabaseSourceLoad;'
+ )(
+ fakeHash, Client, class {},
+ async () => ({ tableCount: tables.size, catalogId: 'catalog-1' }),
+ async (_, callback) => {
+ exporterOpen = true;
+ try { return await callback({ exportedSnapshotId: 'snapshot-1', walLsn: '0/A' }); }
+ finally { exporterOpen = false; }
+ },
+ sourceRuntime
+ );
+ let result, error;
+ try {
+ result = await qualify({
+ profile: { writers }, seed: 'fixture', runId: 'fixture',
+ evidenceWriter: { appendRaw() {} },
+ pgConfig: { host: '127.0.0.1', port: 5432, database: 'isolated', user: 'test', password: 'test-only' }
+ });
+ } catch (caught) { error = caught; }
+ return { result, error, writes, readers, remainingTables: tables.size };
+}
+
+test('all declared source readers join the exported snapshot, not two hardcoded tables', async () => {
+ for (const writers of [2, 5]) {
+ const { result, error, writes, readers, remainingTables } = await qualifyMock(writers);
+ assert.equal(error, undefined);
+ assert.equal(writes.length, writers * 2);
+ assert.equal(new Set(readers).size, writers * 2);
+ assert.equal(result.metrics.joinedReaders, writers);
+ assert.equal(result.metrics.tablesRead, writers * 2);
+ assert.equal(result.metrics.postExportWrites, writers * 2);
+ assert.equal(result.metrics.totalRows, writers * 2000);
+ assert.equal(remainingTables, 0);
+ }
+});
+test('reader seeing post-export rows fails the shared-MVCC-snapshot qualification', async () => {
+ const result = await qualifyMock(2, { wrongJoined: true });
+ assert.match(result.error?.message ?? '', /Joined reader/);
+ assert.equal(result.result, undefined);
+ assert.equal(result.remainingTables, 0);
+});
+test('post-export write must be independently visible outside joined snapshot', async () => {
+ const result = await qualifyMock(2, { missingLive: true });
+ assert.match(result.error?.message ?? '', /not visible/);
+ assert.equal(result.result, undefined);
+ assert.equal(result.remainingTables, 0);
+});
+test('source code no longer substitutes a post-closure write for a concurrent snapshot test', () => {
+ assert.doesNotMatch(source, /tables\.slice\(0, 2\)/);
+ assert.match(source, /await Promise\.all\(Array\.from\(\{ length: profile\.writers \}/);
+ assert.match(source, /postExportWrites: snapshotResult\.postExportWrites/);
+});
diff --git a/tests/qualify-test-execution.test.js b/tests/qualify-test-execution.test.js
new file mode 100644
index 00000000..9d4d569c
--- /dev/null
+++ b/tests/qualify-test-execution.test.js
@@ -0,0 +1,37 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { assertExecutedTap } from '../scripts/qualify/assert-test-execution.js';
+
+const make = ({ tests = 2, pass = 2, fail = 0, cancelled = 0, skipped = 0, todo = 0, entries = ['ok 1 - first', 'ok 2 - second'] } = {}) =>
+ ['TAP version 13', ...entries, '# tests ' + tests, '# pass ' + pass, '# fail ' + fail,
+ '# cancelled ' + cancelled, '# skipped ' + skipped, '# todo ' + todo].join('\n');
+
+test('actual TAP with executed passing cases is accepted', () => {
+ const result = assertExecutedTap(make(), { minimumTests: 2 });
+ assert.equal(result.status, 'EXECUTED_ALL_PASS');
+ assert.equal(result.tests, 2);
+});
+test('a pass exit status with zero actual tests is rejected', () => {
+ assert.throws(() => assertExecutedTap(make({ tests: 0, pass: 0, entries: [] })), /Insufficient/);
+});
+test('all-skipped and partially skipped suites never count as qualified', () => {
+ for (const transcript of [
+ make({ pass: 0, skipped: 2, entries: ['ok 1 - no db # SKIP', 'ok 2 - no db # SKIP'] }),
+ make({ pass: 1, skipped: 1, entries: ['ok 1 - real', 'ok 2 - skip # SKIP'] })
+ ]) assert.throws(() => assertExecutedTap(transcript), /skipped/);
+});
+test('claimed PASS cannot override failed/cancelled/TODO or missing tests', () => {
+ for (const transcript of [
+ make({ pass: 1, fail: 1, entries: ['ok 1 - yes', 'not ok 2 - fail'] }),
+ make({ pass: 1, cancelled: 1 }),
+ make({ pass: 1, todo: 1 }),
+ make({ pass: 1 }),
+ make().replace('# pass 2', '')
+ ]) assert.throws(() => assertExecutedTap(transcript));
+});
+test('fake summaries with no test entries and partial transcripts are rejected', () => {
+ assert.throws(() => assertExecutedTap(make({ entries: [] })), /No completed/);
+ assert.throws(() => assertExecutedTap('TAP version 13\nok 1 - unresolved'), /summary/);
+ assert.throws(() => assertExecutedTap(make().replace('TAP version 13', '')), /header/);
+ assert.throws(() => assertExecutedTap(make() + '\n# pass 2'), /ambiguous/);
+});
diff --git a/tests/spoold.test.js b/tests/spoold.test.js
index db895d0b..ee1e9764 100644
--- a/tests/spoold.test.js
+++ b/tests/spoold.test.js
@@ -161,3 +161,43 @@ test('spoold drives cutover arming and commit against a real service', async ()
await rm(dir, { recursive: true, force: true });
}
});
+
+test('strict scoped loopback HTTP refuses cross-migration read and mutation before dispatch', async () => {
+ const calls = [];
+ const service = {
+ status: async id => { calls.push(['status',id]); return {id}; },
+ run: async req => { calls.push(['run',req.migrationId]); return {id:req.migrationId}; },
+ abortCutover: async id => { calls.push(['abort',id]); return {id}; }
+ };
+ const identityToken = 'S'.repeat(32);
+ const daemon = createSpoolDaemon({service, requireMigrationScopes:true, localIdentities:[{
+ token:identityToken, principal:'operator:alice', commands:['status','run','abort-cutover'],
+ migrationIds:['migration-A']
+ }]});
+ const address = await daemon.listen(0);
+ try {
+ for(const [body,expected] of [
+ [{command:'status',migrationId:'migration-B'},403],
+ [{command:'run',request:{principal:'operator:alice',migrationId:'migration-B'}},403],
+ [{command:'abort-cutover',migrationId:'migration-B'},403],
+ [{command:'run',request:{principal:'operator:bob',migrationId:'migration-A'}},403],
+ [{command:'run',request:{principal:'operator:alice',migrationId:'migration-A'},migrationId:'migration-B'},403],
+ [{command:'status',migrationId:'migration-A'},200],
+ [{command:'run',request:{principal:'operator:alice',migrationId:'migration-A'}},200]
+ ]) {
+ const actual = await request({port:address.port,token:identityToken,body});
+ assert.equal(actual.status,expected,JSON.stringify(body));
+ }
+ assert.deepEqual(calls,[['status','migration-A'],['run','migration-A']]);
+ } finally { await daemon.close(); }
+});
+
+test('strict scoped daemon refuses a single shared legacy token or missing scopes at startup', () => {
+ const service={status:async()=>null};
+ assert.throws(()=>createSpoolDaemon({service,token:TOKEN,requireMigrationScopes:true}),{
+ code:'INVALID_DAEMON_CONFIG'
+ });
+ assert.throws(()=>createSpoolDaemon({service,requireMigrationScopes:true,
+ localIdentities:[{token:'X'.repeat(32),principal:'operator:alice',commands:['status'] }]
+ }),{code:'INVALID_DAEMON_CONFIG'});
+});